1. Subset as a strategy
By this point, we already have two working forms of container-backed tests: a narrow repository path and a targeted full-context flow. One last question remains: which of them should we keep in the suite at all so that real PostgreSQL adds confidence instead of just slowing the run down.
A container-based subset is not a “lightweight version of quality”. It is a way to choose a few tests with maximum ROI and avoid turning the PostgreSQL path into the new default for the entire project. If you can name each such test and explain in one sentence which DB-specific risk it catches, the subset is assembled correctly.
To reinforce this idea, let us picture it as layers of a cake (yes, testing is about food again; apparently, we are hungry):
flowchart TD
Unit["Unit tests (without Spring)
fast, cheap, precise"]
Slice["Slices (@WebMvcTest, @JsonTest, @DataJpaTest)
fast, local"]
Integration["@SpringBootTest / live-server
expensive, but needed"]
Containers["Container-based subset
very expensive, but gives confidence where needed"]
Unit --> Slice --> Integration --> Containers
A container-based subset is the top layer, not the foundation. It should not hold up the whole house; it should cover the gaps left by all the other layers.
2. Four filters: risk, value, stability, cost
When we decide which tests deserve a container, it is useful not to try to “guess” the right list right away, but to run candidates through a few simple filters. It is like a job interview: the container is expensive, so not every test gets the position.
Below is a table you can keep in mind (or right in the README for the team if you like order and suffer a little from chaos in tests):
| Filter | The question we ask | What counts as a good answer | Example from ContentHub |
|---|---|---|---|
| DB-specific risk | Can the embedded DB “lie”? | Yes, behavior depends on PostgreSQL (dialect/constraints/locking) | Uniqueness of slug, timestamp sort order, optimistic locking |
| Business value | If this breaks, will it hurt? | Yes, it is a critical scenario or a key invariant | Published list/read, workflow publish/approve, unique slug |
| Setup stability | Can we make the data repeatable? | Yes: Flyway, @Sql, a clear seed | @Sql("/sql/published-articles.sql") for deterministic order |
| Cost | Does the container pay off here specifically? | Yes: one test gives a lot of confidence, otherwise slice/unit is cheaper | 1-2 repository tests + 1 integration flow, but not hundreds |
It is important to note a subtle point: the “Cost” filter is not about stinginess. Cost means feedback speed, Docker dependence, extra points of failure, and diagnosis time. A container test that fails requires more attention than a unit test: you start wondering what exactly broke — code, migrations, Docker, the network, the image, or machine resources.
And that is exactly why the subset should be small: the fewer expensive tests you have, the more often you actually run them and the more you trust their green status.
3. ContentHub candidates: 3-4 tests for PostgreSQL
We are not inventing new forms of tests here. The forms are already clear: ArticleRepositoryPostgresContainerTest for the repository path and ArticlePublicationFlowPostgresIntegrationTest for a targeted full-context flow. Now we build a short shortlist from them with the best ROI.
A separate migration-only test sometimes also makes sense if the schema changes often and hurts when it does. But in many projects, container-backed tests already provide this signal: they start on a clean PostgreSQL instance and do not hide Flyway problems behind an embedded path.
Repository test: unique slug
This is almost a mandatory candidate: one test, a lot of confidence. It is not about whether the “repository can save an entity”, but about whether the invariant is really protected by the database.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryPostgresContainerTest {
@Test
void rejectsDuplicateSlug() {
// Test idea: the database should "forbid" a duplicate slug on its own, through a constraint.
// 1) save the first article with slug="same"
// 2) try to save the second article with slug="same" and flush
// 3) expect an exception about uniqueness violation (constraint violation)
//
// Important: it is exactly flush (saveAndFlush / entityManager.flush) that forces the DB to check the constraint here and now.
}
}
Yes, the example is intentionally “skeletal”: in real code it is the same repository path we already have in our container-based @DataJpaTest. Only the chosen risk changes.
Repository test: deterministic ordering of published articles
If the public list depends on an exact ORDER BY, one such test is often more useful than ten generic CRUD checks. On an embedded DB, these things sometimes look “accidentally stable”.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
class PublishedArticlesOrderingPostgresTest {
@Test
@Sql("/sql/published-articles.sql")
void ordersByPublishedAtDesc() {
// @Sql here is needed not "for beauty", but for determinism:
// we predefine fixed publishedAt and slug so that the order is testable.
//
// The idea:
// 1) run the repository method with sorting by publishedAt DESC
// 2) compare the actual slug order with the strictly expected one
//
// Important: if sorting is "not there" or timestamp is interpreted differently,
// this will show up exactly on real PostgreSQL.
}
}
Why is this a candidate for the subset? Because it checks query behavior, sorting, and the fact that timestamps live in real PostgreSQL “as in production” all at once.
Mini test: optimistic locking (if you have already seen the benefit)
If the project has version and you showed the locking model earlier, then one test on a real DB can provide a useful “anchor of confidence”. It is not a new foundation for the suite, just targeted protection for a genuinely unpleasant class of bugs.
import org.junit.jupiter.api.Test;
class ArticleOptimisticLockingPostgresTest {
@Test
void failsOnStaleUpdate() {
// The essence of optimistic locking:
// 1) read the same entity in two "snapshots" (two transactions / two EntityManagers)
// 2) change and flush the first snapshot — version increased in the DB
// 3) change and flush the second snapshot — it is still on the old version
// 4) expect a failure on conflict (stale update / optimistic lock exception)
//
// Important: on the real DB you are checking exactly the scenario that will happen in production.
}
}
Such a test makes sense only if version is truly part of the domain model, not a decorative annotation “just in case”.
Full-context flow: checking the persisted effect
And finally, one end-to-end scenario. It should not be “the biggest one”, but the most demonstrably useful one. For example, approve/publish: it changes status, writes time, and affects public visibility.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class ArticlePublicationFlowPostgresIntegrationTest {
@Test
void approvePersistsPublishedStatus() {
// The container is needed here not for HTTP, but for the "persisted effect":
// 1) call HTTP approve/publish (through MockMvc or TestRestClient)
// 2) then read the state back from the repository (or make a separate API request)
// 3) verify that PUBLISHED + the publication date (if it is part of the invariant) were really written to the DB
//
// The idea: an "ok" HTTP response may happen, but the DB write may not. The container catches exactly that.
}
}
Here it is important to remember the rule: in a container-based full-context test, do not stop at status().isOk(). The container is not there to verify the HTTP status, but to make sure that the real DB accepted the correct state and that it can be read back.
4. Shared PostgreSQL setup: a common config
As soon as the subset stops being “one test for an experiment”, a normal desire appears: “Let us not copy the same container declaration in every class.” That is a good desire. But there is a fine line: you can either get a neat shared config in 10 lines, or you can build a “magical” hierarchy where the container starts somewhere deep down and nobody understands why the test became container-based at all.
The right reuse goal sounds like this: reduce copy-paste without hiding why the test is expensive.
Transparent shared config via @TestConfiguration
The most instruction-friendly option is a separate test configuration that you import into selected tests. It is small, readable “in one jump”, and the import makes it clear that the test is special.
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration
class SharedPostgresContainerConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
// The container should be as predictable as possible:
// pin the image version so that behavior in CI and locally matches.
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
The connection:
import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
@Import(SharedPostgresContainerConfig.class) // It is clearly visible: the test requires a container
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryPostgresContainerTest {
// There is intentionally no "magic" here:
// a single @Import makes it clear why the test is expensive and why it needs Docker.
}
Pay attention to the “readability of intent”: a single @Import(SharedPostgresContainerConfig.class) shows that this is not an ordinary test. And the test name (...PostgresContainerTest) shows why it is expensive.
“BasePostgresTest” is often worse
A very popular path is to create an abstract class like AbstractPostgresContainerTest and inherit from it. Technically it works, but methodologically it is bad because it hides the infrastructure “somewhere below”. A couple of months later you open a test and only see extends AbstractPostgresContainerTest, and then archaeology begins.
If you do create a shared class, it should be as “non-magical” as possible: no tricky overrides, no conditional startup, no “let us configure the logger too”, no “let us override half the application”. But even then, importing the configuration is often clearer for a junior audience.
5. Naming and structure: do not mask expensive tests
When the subset is small, the problem of “where it lives” seems overstated. But once 5-10 container tests appear (and that is already a lot for a course), you suddenly start getting confused: which tests require Docker, which can run everywhere, and which are the “late booster”.
The good news is that this is solved with simple naming rules, without a separate framework.
A normal convention for ContentHub looks like this: the test class name explicitly says that it is container-based and that it is about PostgreSQL. For example, ArticleRepositoryPostgresContainerTest and ArticlePublicationFlowPostgresIntegrationTest. Even if a person opens the file on a phone in the subway and has no context, they already understand: “aha, Docker is needed”.
You can also keep container tests in a separate package or subpackage, but here it is important not to overdo it. If you spread tests across packages too much, you create extra “navigation pain”. Usually it is enough that they sit next to the ordinary tests of the layer, but differ by name, for example:
.../repository/ArticleRepositoryDataJpaTest — fast data-slice,
.../repository/ArticleRepositoryPostgresContainerTest — expensive, DB-specific subset.
This helps preserve the mental model: “it is the same layer, but a different runtime”.
6. Signs of subset drift and trimming it back
There is one very practical criterion that almost never fails: if you cannot list container-based tests one by one, then the subset is no longer a subset, but “containers everywhere because that is how it happened”.
Another marker is risk duplication. For example, you check the uniqueness of slug in a regular @DataJpaTest on an embedded database, then the same thing in a container, then once more through a full-context flow with a container. In the end, you spend time three times, while confidence grows not three times, but at most one and a half times (and even that, if you are lucky). A good subset usually follows the principle “one risk — one strongest test” and leaves the rest to lower layers.
A similar symptom is when the container “by habit” gets pulled into web-slice or JSON tests. If you connect PostgreSQL to @WebMvcTest, that almost always means the layer boundary has been lost somewhere. A web-slice checks the HTTP boundary; it should not care what the database lives on if you are not doing full wiring (and @WebMvcTest does not do that by design).
And finally, the psychological symptom: tests become so slow that you run them “only before release”. In an educational project, this is especially sad, because you lose feedback and the tests become a museum exhibit “just in case”.
7. Typical mistakes when working with a subset
Mistake #1: moving the entire @DataJpaTest package to containers.
This looks like an increase in quality, but usually it turns into slower development and lower trust in the suite. Most repository tests are not sensitive to a specific dialect, which means the container adds no new information there. Selection works much better: 1-2 tests for constraints and ordering give more confidence than dozens of “container CRUD” tests.
Mistake #2: shared container setup turns into magic.
When the container is hidden in a deep hierarchy of base classes or in “super utilities”, newcomers stop understanding why the test is expensive and what it needs. Then “random” failures appear, and the team starts treating symptoms instead of causes. Shared config should be short and visible: @Import(SharedPostgresContainerConfig.class) is almost the ideal level of transparency.
Mistake #3: a full-context test checks only the HTTP status.
If you spun up a real PostgreSQL and a full context, but in the test you wrote only status().isOk(), you made an expensive check of a cheap property. HTTP status is perfectly checked in a web-slice, and even in a full-context test without a container. A container is justified when you confirm the persisted effect: the article status in the database, the uniqueness of slug, the correct publication date, and read-back through the repository.
Mistake #4: no deterministic data and a “floating order”.
Container-based tests are often written “in a hurry” and people forget that the order of results without explicit sorting is not a promise, but luck. And luck in tests usually ends on CI. For ordering scenarios, use @Sql with clear dates/slugs and check the expected order strictly. If the test should be strict, the data should be strict too.
Mistake #5: the container is added to tests that do not need it.
If you feel like dragging PostgreSQL into unit, JSON, or @WebMvcTest, that is almost always a signal that the subset is already spreading. Here it is better to stop and ask again: which exact DB-specific risk are we trying to catch. If there is no answer, the container is probably unnecessary.
GO TO FULL VERSION