# Build Trustworthy Database Tests with Testcontainers

> A disposable database guarantees availability and a known image; it says nothing about whether your schema matches production or which test owns which rows. This guide covers separating container lifetime from data isolation, the singleton pattern the JUnit 5 extension deliberately does not provide, running your real migration set rather than letting an ORM invent a schema, choosing between a schema, a rollback and an owned key range, what the jdbc:tc URL mode is actually for, and a concurrent-insert test that asserts SQLSTATE 23505 rather than a message.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-30T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/testcontainers-database-integration-tests-trustworthy

- Tags: testcontainers, junit, java, integration-testing, postgresql, database-testing

**Quick answer:** Container lifetime and data isolation are two separate decisions, and conflating them is what makes a container-backed suite slow *and* order-dependent. Start one container per JVM, pin its tag to the version production runs, build the schema by executing your real migrations, and isolate per test at the data layer: a schema, a rollback or an owned key range. Then assert something a schema-less fake could not answer: a constraint violation by SQLSTATE, not by message.

A disposable database removes one problem and is often credited with removing three. It guarantees the database is *available* and that it starts from a known image. It says nothing about whether your schema matches production, nothing about which test owns which rows, and nothing about the clock.

The Testcontainers documentation is clear-eyed about the trade it is offering. Compared to an in-memory substitute: "Testcontainers is not as performant as H2, but does give you the benefit of 100% database compatibility (since it runs a real DB inside of a container)." And on the same page, unprompted: "of course, it's still important to have as few tests that hit the database as possible."

Take both halves seriously. The tests that earn a container are the ones asserting something only the real engine knows: a constraint, a transaction boundary, a type coercion, a collation. Everything below is about making those few tests trustworthy, which is a different goal from making them fast.

**Do not run these classes concurrently.** The Testcontainers JUnit 5 integration states on its own page that it "has only been tested with sequential test execution", and that concurrent use is unsupported and may have unintended side effects. If the rest of your suite runs in parallel, mark the container-backed classes `@Execution(SAME_THREAD)` or give them a separate suite. Do not rely on nobody flipping the default later. Versions checked on 2026-09-07: Testcontainers for Java 2.0.5, and PostgreSQL's currently supported minor releases 18.6, 17.11, 16.15, 15.19 and 14.24.

## Two axes that get collapsed into one

Ask two questions separately and most of the design falls out.

**How long does a container live?** The JUnit 5 integration answers this from where you put the field. "Containers declared as static fields will be shared between test methods. They will be started only once before any test method is executed and stopped after the last test method has executed. Containers declared as instance fields will be started and stopped for every test method."

**What owns the data?** That is unrelated to the above, and the extension has no opinion on it. A shared container with no data strategy gives you tests that pass in the order they were written.

The instance-field form looks like the safe default and is almost never the right one: you pay a full database start-up per test method to get isolation you can obtain far more cheaply. The per-class form is a reasonable middle. And for a suite of any size the right container lifetime is one per JVM, which the extension deliberately does not provide:

```
abstract class DatabaseTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:18.6")
                .withDatabaseName("orders")
                .withUsername("orders_app")
                .withPassword("orders_app");
        POSTGRES.start();
        Migrations.applyTo(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
    }
}
```

The documentation calls this the singleton container pattern and states plainly that "there is no special support for this use case provided by the Testcontainers extension", offering exactly this shape instead: a static initialiser in an abstract base class, started once when the class loads. Cleanup is handled for you: "at the end of the test suite the Ryuk container that is started by Testcontainers core will take care of stopping the singleton container."

One constraint on the shared-container forms is easy to trip over while restructuring a suite: "shared containers can't be declared inside nested test classes. This is because nested test classes have to be defined non-static and can't therefore have static fields." If you have organised tests by precondition using `@Nested`, the container belongs on the enclosing class or on the base class, not inside the group that uses it.

## The image tag is part of the assertion

An unpinned or loosely pinned image turns every test result into a claim about whatever was cached on that machine. Worse, it makes a green suite compatible with a production incident: the behaviour you verified was a different engine's.

Pin the tag to the version production actually runs, minor included. PostgreSQL ships a major release roughly annually and supports it "for 5 years after its initial release", with minor releases "at least once every three months". So the gap between `postgres:18` and what production runs can be four patch levels of behaviour change. Read the tag from configuration rather than hard-coding it in two places:

```
// One source of truth, shared with the deployment manifest.
private static final String IMAGE =
        System.getProperty("db.image", "postgres:18.6");
```

Two related mechanics are worth knowing before you reach for a variant image. `PostgreSQLContainer` accepts several compatible images, but a substitute has to be declared as such: the documented form is `DockerImageName.parse("postgis/postgis:16-3.4-alpine").asCompatibleSubstituteFor("postgres")`, and without that call the module refuses an image it does not recognise. And adding the module does not give you a driver: "adding this Testcontainers library JAR will not automatically add a database driver JAR to your project", so the JDBC driver is a separate dependency whose version is also part of what you are testing.

## The schema has to come from your migrations

This is the step where most container-backed suites quietly stop being integration tests. An ORM asked to create the schema from entity mappings produces *a* schema: one that reflects your annotations rather than the accumulated history of your production database. Every constraint added by hand in a migration, every partial index, every check, every default, every collation is absent. The suite then verifies the code against a database that has never existed.

Run the real migrations against the container, once, at the point the container becomes available. Testcontainers provides a supported hook for this even in its most minimal mode: the JDBC URL parameter `TC_INITFUNCTION`, whose target "must be a public static method which takes a `java.sql.Connection` as its only parameter", and whose documented purpose is "to allow you to trigger database schema migration tools". The docs' own example comment says "run schema setup or Flyway/liquibase/etc DB migrations here".

With a container object rather than a URL, call your migration tool directly after `start()`, as in the base class above. Either way, two properties matter more than the mechanism.

The migration set must be the same artefact production uses, read from the same directory, not a test copy. A test-only schema file is a second implementation of your schema and it will drift, silently, in the direction of whatever made the tests pass.

And the migration run must be observable. If the suite starts and the tables are simply there, you cannot distinguish "migrations ran" from "the previous container was reused with last week's schema". Log the applied version and assert on it once, in a single test whose only job is to fail loudly when the schema is not what the repository says.

```
@Test
void theContainerRunsTheSameMigrationsAsProduction() throws SQLException {
    try (Connection connection = connect();
         Statement statement = connection.createStatement()) {

        ResultSet version = statement.executeQuery(
                "select max(version) from schema_history where success");
        assertTrue(version.next());
        assertEquals(Migrations.latestVersionOnDisk(), version.getString(1));

        ResultSet server = statement.executeQuery("show server_version");
        assertTrue(server.next());
        assertTrue(server.getString(1).startsWith("18."),
                () -> "unexpected engine version: " + server.getString(1));
    }
}
```

## Choosing the data-isolation unit

With one container for the JVM, isolation is a data question with three defensible answers. They differ in what they can test, which is the property that should decide it, not which is fastest.

**A schema per test class.** Create a schema in the shared container, run migrations into it, set the connection's search path to it, drop it afterwards. Slowest of the three by a wide margin, because migrations run per class, and the only one that can test anything schema-level: a migration itself, a DDL-emitting feature, a permission model.

**A transaction rolled back per test.** Open a transaction in setup, never commit, roll back in teardown. Fast, and the isolation is genuinely complete. The limitation is structural rather than a matter of taste: code under test that manages its own transactions cannot be observed properly from inside an outer one. Anything asserting commit-time behaviour (a deferred constraint, a commit trigger, what a second connection can see) is invisible, because the commit never happens. If the thing you are testing *is* the transaction boundary, this strategy cannot see it.

**An owned key range per test.** Every test writes rows it derived from its own identity, and reads only those. Fast, allows real commits, and survives concurrent execution. The cost is discipline: one query without the ownership predicate and the test starts seeing its neighbours' rows. Make it structural rather than remembered:

```
// Derived from the test's own identity, so no two tests can collide and
// nothing has to be cleaned up between them.
private String ownedEmail(TestInfo info) {
    return info.getTestMethod().orElseThrow().getName().toLowerCase() + "@example.test";
}
```

The decision rule is short. If the assertion is about the schema, pay for a schema. If the assertion is about a commit, use owned keys. Otherwise roll back, because it is the cheapest option that is genuinely airtight.

## What the JDBC URL mode is good for

Testcontainers offers a second route that needs no container object at all: insert `tc:` after `jdbc:` and the driver starts a container for you. So `jdbc:tc:postgresql:18.6:///orders` works, and the documentation notes that "the hostname, port and database name will be ignored; you can leave these as-is or set them to any value", which is why the host-less triple-slash form is used in the examples.

It is the right tool in exactly one situation: when the thing under test reads a JDBC URL from configuration and you do not want to change its wiring to accommodate a test. Point the configuration at a `tc:` URL and the application under test is unmodified.

Its default lifecycle is the trap. "By default database container is being stopped as soon as last connection is closed." A test that closes its connection between phases gets a fresh, empty database for the second phase, and the symptom is a missing row rather than an error that says a container restarted. The documented fix is a URL parameter:

```
jdbc:tc:postgresql:18.6:///orders?TC_DAEMON=true&TC_INITFUNCTION=com.example.Migrations::apply
```

`TC_INITSCRIPT` is the fixed-SQL sibling, reading from the classpath by default or from disk with a `file:` prefix. Prefer `TC_INITFUNCTION` for anything that should match production, because a script is another schema definition to keep in step.

One optimisation is safe here and worth taking on a laptop: `TC_TMPFS=/var/lib/postgresql/data:rw` puts the data directory in host memory. The docs attach the obvious caveat: "the data will be lost when the container stops", which is exactly what a test database wants.

## A uniqueness test needs two connections

Here is a test that justifies the container. Two concurrent registrations for the same email address must produce one account and one rejection, and the rejection must be the database's, not a check the application did first.

The mechanics matter because PostgreSQL blocks rather than failing immediately: the second insert waits on the unique index until the first transaction resolves. So the second insert has to be on another thread, or the test deadlocks against itself.

```
@Test
void rejectsTheSecondAccountWithTheSameEmail() throws Exception {
    String email = "ada@example.test";
    ExecutorService other = Executors.newSingleThreadExecutor();

    try (Connection first = connect(); Connection second = connect()) {
        first.setAutoCommit(false);
        second.setAutoCommit(false);

        insertAccount(first, email);

        // Blocks on the unique index until `first` resolves, so it cannot
        // run on this thread.
        Future<SQLException> contender = other.submit(() -> {
            try {
                insertAccount(second, email);
                second.commit();
                return null;
            } catch (SQLException rejected) {
                return rejected;
            }
        });

        first.commit();

        SQLException rejected = contender.get(10, TimeUnit.SECONDS);
        assertNotNull(rejected, "the second registration must not succeed");
        // Assert the SQLSTATE, not the message: 23505 is unique_violation.
        assertEquals("23505", rejected.getSQLState());
        assertEquals(1, countAccountsWith(email));
    } finally {
        other.shutdownNow();
    }
}
```

Three things make this test worth its runtime. It asserts on `SQLSTATE 23505`, which PostgreSQL's error-code appendix defines as `unique_violation` under class 23, integrity constraint violation: a stable contract, unlike the message text, which is localised and version-dependent. It counts the surviving rows, so a bug that rejects *both* inserts fails the test rather than passing it. And no fake or in-memory substitute can produce this result, because the behaviour being asserted is the index's.

Two neighbouring codes are worth knowing while writing tests in this area: `23503` is `foreign_key_violation`, and `40001` is `serialization_failure`, which is what a retry loop under a serializable isolation level must handle.

Note that the extra thread here is inside a single test, which is a different thing from running the suite concurrently. The Testcontainers JUnit 5 extension states its own limit twice on its page: it "has only been tested with sequential test execution", and using it concurrently is unsupported. Keep container-backed classes out of the concurrent set, and mark them `@Execution(SAME_THREAD)` explicitly if they share a run with tests that are.

## Container reuse is a laptop optimisation

Sooner or later somebody proposes enabling reuse to cut start-up time. The feature exists, and the documentation surrounds it with warnings that should decide the question for CI.

It is "still an experimental feature and the behavior can change", the containers "won't stop after all tests are finished", and, stated outright, "reusable containers are not suited for CI usage and as an experimental feature not all Testcontainers features are fully working (e.g., resource cleanup or networking)".

Note also how it is switched on, because the mechanism is the point. Reuse requires the environment variable `TESTCONTAINERS_REUSE_ENABLE=true` or `testcontainers.reuse.enable=true` in the developer's own `~/.testcontainers.properties`, and explicitly "not through classpath properties file". You cannot commit it. That design decision is telling you where it belongs: an opt-in a developer makes for their own machine, never a property of the build.

The other half of the contract is that reuse and lifecycle management are mutually exclusive. You must "start the container manually by calling `start()` method, do not call `stop()` method directly or indirectly via try-with-resources or JUnit integration", and the configuration must be byte-identical between runs or you get a new container instead. A reused container also keeps last run's rows and last run's schema, which is precisely the state the migration-version assertion above exists to catch.

## The evidence to print once per run

A container-backed suite that passes should leave behind enough to answer "what did it actually test", because a year from now the answer will be contested during an incident. Four lines, emitted once from the base class after migrations complete:

```
image            postgres:18.6
server_version   18.6 (Debian 18.6-1.pgdg13+1)
migrations       V037__add_accounts_email_unique.sql (37 applied)
isolation        rollback per test; 3 classes on owned keys
```

The image tag and the server version should agree, and when they do not (because a substitute image was resolved, or a cached layer is stale), that line is the only place it shows. The migration count catches a container that came back with an old schema. And recording the isolation strategy per class is what makes a later order-dependence bug diagnosable, because the first question will be which tests were committing.

## Apply this now

Find out which schema your container-backed tests are running against. If an ORM is creating it from mappings, that is today's job and nothing else on this list matters until it is done: point the container at your real migration set and fix whatever breaks, because the breakages are constraints your tests have never been subject to.

Then check the image tag. If it is a bare major, a moving alias, or absent, pin it to the exact minor release production reports from `show server_version`, and add the assertion that fails when the two diverge.

Last, count the container starts in one run. If it equals your test-method count, move the container to a static field or a base class and pick a data-isolation unit deliberately: rollback unless the test is about a commit. The suite should get substantially faster and, more importantly, its failures should stop depending on which test ran first.

## Questions about container-backed database tests

### How many tests should touch a real database at all?

Few, and Testcontainers' own documentation says so. The set worth the cost is the one whose assertions the engine alone can answer: constraints, transaction boundaries, isolation-level behaviour, type and collation semantics, and the migrations themselves. Business rules layered on top of a repository do not need a container, and a suite where every service test starts a database is usually one missing seam away from being mostly unit tests.

### Is a database service in CI not simpler than a container per build?

Simpler to configure, weaker as evidence, and the difference is stated in the docs: with a container "the database always starts in a known state, without any contamination between test runs or on developers' local machines." A long-lived CI database accumulates state, and the first symptom is a test that passes on the runner and fails for a new joiner. The genuine argument for the shared service is start-up cost, which the singleton pattern already recovers most of.

### Does the container need production's configuration as well as its version?

For most tests the defaults are fine, and for a few they are the thing under test. Settings that change observable behaviour (`default_transaction_isolation`, `lc_collate`, `timezone`, `statement_timeout`) belong in the container's command or an init script when a test depends on them, and the decision is per assertion. A test asserting that a serialization failure is retried must set the isolation level, or it is asserting nothing.

### How do I keep container start-up out of the inner development loop?

Put the database tests in their own Gradle suite, so `./gradlew test` never starts a container and `./gradlew integrationTest` is a deliberate act. That is the part-four topic in this series, and it addresses the same complaint reuse is usually reached for, without the experimental caveats. Reuse remains a reasonable personal setting on top of that.

### Should a container that fails to start fail the build, or skip?

Fail. A skip on a missing container runtime means the pull request that breaks a constraint merges green on any machine where Docker was not running, which is the failure mode the whole arrangement was supposed to prevent. If some contributors genuinely cannot run a container runtime, the honest arrangement is a separate suite that a subset of contributors and all of CI run, not a conditional skip inside the suite that gates merges.

## Primary references

- [Testcontainers for Java — Jupiter / JUnit 5](https://java.testcontainers.org/test_framework_integration/junit_5/). The container-lifetime rule quoted in the body: a static `@Container` field is "shared between test methods", started once before the first and stopped after the last, while an instance field is "started and stopped for every test method". Also that "shared containers can't be declared inside nested test classes" because those must be non-static, and the restriction the page repeats twice: the extension "has only been tested with sequential test execution", so concurrent use is unsupported. Testcontainers 2.0.5.

- [Testcontainers for Java — Manual container lifecycle control](https://java.testcontainers.org/test_framework_integration/manual_lifecycle_control/). The singleton container pattern verbatim, including that "there is no special support for this use case provided by the Testcontainers extension", the static-initialiser form in an abstract base class, and that "at the end of the test suite the Ryuk container that is started by Testcontainers core will take care of stopping the singleton container."

- [Testcontainers for Java — JDBC support](https://java.testcontainers.org/modules/databases/jdbc/). The `jdbc:tc:` URL scheme and that "the hostname, port and database name will be ignored"; that "by default database container is being stopped as soon as last connection is closed" and `TC_DAEMON=true` changes that; `TC_INITSCRIPT` reading from the classpath or from a `file:` path; `TC_INITFUNCTION`, whose target "must be a public static method which takes a `java.sql.Connection` as its only parameter" and whose documented purpose is "to allow you to trigger database schema migration tools"; `TC_TMPFS` with the warning that "the data will be lost when the container stops"; and `getJdbcUrl()`, `getUsername()` and `getPassword()` on a container object.

- [Testcontainers for Java — Reusable Containers (Experimental)](https://java.testcontainers.org/features/reuse/). That reuse is "still an experimental feature and the behavior can change", that reused containers "won't stop after all tests are finished", and that "reusable containers are not suited for CI usage and as an experimental feature not all Testcontainers features are fully working (e.g., resource cleanup or networking)". Also the opt-in mechanism: `TESTCONTAINERS_REUSE_ENABLE=true` or `testcontainers.reuse.enable=true` in `~/.testcontainers.properties`, and explicitly "not through classpath properties file", and the requirement to call `start()` manually while never calling `stop()` "directly or indirectly via try-with-resources or JUnit integration", with an identical container configuration between runs.

- [Testcontainers for Java — Database containers](https://java.testcontainers.org/modules/databases/). The stated trade against an in-memory substitute: "Testcontainers is not as performant as H2, but does give you the benefit of 100% database compatibility (since it runs a real DB inside of a container)", that a container means "the database always starts in a known state, without any contamination between test runs or on developers' local machines", and the accompanying advice that "it's still important to have as few tests that hit the database as possible".

- [Testcontainers for Java — Postgres Module](https://java.testcontainers.org/modules/databases/postgres/). The `PostgreSQLContainer` constructor taking an image name, the compatible-image form `DockerImageName.parse(…).asCompatibleSubstituteFor("postgres")`, and that "adding this Testcontainers library JAR will not automatically add a database driver JAR to your project".

- [PostgreSQL — Appendix A: Error Codes](https://www.postgresql.org/docs/current/errcodes-appendix.html). That `23505` is `unique_violation` within "Class 23 — Integrity Constraint Violation", alongside `23503 foreign_key_violation` and `40001 serialization_failure`, the stable contract the concurrency test asserts on instead of the message text.

- [PostgreSQL — Versioning Policy](https://www.postgresql.org/support/versioning/). That a new major version arrives "about once a year", that each major is supported "for 5 years after its initial release", and that minor releases land "at least once every three months", the reason pinning to a bare major leaves several patch levels of behaviour unaccounted for. The currently supported minors at the time of checking were 18.6, 17.11, 16.15, 15.19 and 14.24.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Build Trustworthy Database Tests with Testcontainers](https://automationtester.in/blog/automation-tutorials/testcontainers-database-integration-tests-trustworthy) by Shashank Rawlani.
