Testcontainers
Testcontainers starts disposable dependencies from your test code so you can verify integration behavior against the actual service type. Use it when SQL semantics, driver behavior, message handling, or another dependency contract matters enough that a mock is insufficient.
A fresh database container removes one source of shared state. It does not automatically make every test independent or reproduce production. The useful design includes the runtime, image, readiness condition, schema, fixture ownership, and cleanup path.
TL;DR
- Exercise dependency behavior with a real database or service process rather than assuming a mock reproduces it.
- Choose a container lifetime and a separate data-reset strategy; sharing a process is different from sharing test records.
- Wait for the condition the test actually needs. A running container or open port can precede application readiness.
- Resolve the runtime-assigned host and port instead of hard-coding a local address.
- Make CI failures diagnosable with bounded timeouts, image identity, startup logs, and a clear distinction between infrastructure failure and assertion failure.
Quick Example
This pytest example verifies that PostgreSQL rejects a duplicate value under a unique constraint. Run it only in a disposable development or CI environment with a supported Docker-compatible runtime available. Install pytest, testcontainers[postgres], and psycopg[binary] in a project virtual environment, record the resolved versions, then save this as test_database.py and run python -m pytest -q test_database.py.
The expected outcome is one passing test. The row survives because its transaction was committed before the duplicate insert; the rollback clears the failed transaction, as described in Psycopg's transaction documentation. The fixture owns a new container for this test and exits its context afterward. The Python PostgreSQL module documents the container and the driver-free connection URL option. The import above uses the community namespace available in the checked Testcontainers 4.15.0 package; older examples use the compatibility import testcontainers.postgres.
This illustrates a database constraint, not an application's idempotency implementation. A production insert path may translate duplicate errors or use a conflict clause; test that application behavior separately. No production credentials or data belong in this fixture.
Core Concepts
A dependency process is part of the test
The Python getting-started guide provisions an on-demand PostgreSQL dependency from test code. That keeps dependency setup near the assertions instead of relying on an undocumented shared database.
The benefit is concrete: a query runs through a real driver against PostgreSQL. It can expose a constraint, transaction, or SQL behavior that a substitute might miss. This still says little about production permissions, replication, backups, extensions, or connection-pool limits unless the test configures and exercises them.
Process startup and useful readiness differ
Testcontainers for Java explicitly separates startup checks from wait strategies. The former concerns process state; the latter concerns a usable testing condition. Available APIs and defaults differ between language libraries and modules.
For a database, successful authenticated queries may be the relevant condition. For an application container, readiness might also require migrations and a required broker connection. Define that condition rather than inserting an arbitrary delay. A ten-second sleep is a guess with a stopwatch attached.
Lifecycle and data isolation are separate
A function-scoped fixture can give each test a fresh database process. A session-scoped fixture starts fewer processes but shares them across tests. Neither choice alone specifies how schemas, records, or identifiers are isolated.
If tests share a container, choose a reset mechanism appropriate to how the application connects. A rollback in the test's connection cannot undo committed work performed by a separate application connection. Consider per-test schemas or databases, explicit reset operations, or fresh containers where stronger separation is worth the startup cost.
Choose the Smallest Useful Integration Boundary
Start with one integration risk. Building an entire platform for a single query test increases the number of unrelated failure causes. Conversely, stopping at a database test is insufficient when the defect concerns an HTTP handler or background worker. Name the boundary in the test so future maintainers understand its evidence.
For migrations, prefer the application's real migration mechanism over a hand-maintained copy of the schema. The small example creates a table directly to make the constraint demonstration self-contained. That convenience should not become a second schema definition that silently diverges from the application.
Make CI Startup Failures Useful
First confirm the worker can reach its container runtime. A Docker command installed on the machine does not prove the engine is running or reachable from the test process. Nested CI containers also require correct host, port, and networking configuration; inspect the runner's documented setup.
Then separate the phases: runtime discovery, image retrieval, container startup, readiness, schema setup, test action, and cleanup. Keep logs for the phase that failed, while excluding credentials and sensitive fixture values. Give waits finite timeouts so a broken dependency cannot occupy a worker indefinitely.
Select images deliberately. A major-version tag is convenient for a tutorial but can change its contents. Pin approved image identities when repeatability requires it, and maintain a controlled update process so pinning does not become permanent neglect. Test against the production-relevant database version and extensions, rather than whichever image happens to be cached.
Runtime access is a security boundary. Do not casually expose a privileged container socket to untrusted pull-request code. Use an appropriately isolated runner or an approved managed execution option with a documented trust model. Test convenience is not permission to broaden infrastructure access.
Reuse Without Hiding State
Measure where time is spent before changing fixture lifetimes. Image downloads, process startup, migrations, fixture insertion, and application boot suggest different optimizations. Caching an approved image can help download overhead without sharing a running database between independent tests.
Treat explicit reusable-container features as distinct from ordinary fixture sharing. The Java project's experimental reuse documentation says those containers remain running and are unsuitable for CI. Do not assume another language library has identical switches or lifecycle behavior.
Before adopting any reuse mechanism, run the suite in reversed or randomized order and repeat it in a clean environment. If results change, fix data ownership rather than adding retries that conceal the coupling. A repeatable false result is still false, so retain meaningful assertions after optimizing setup.
Best Practices
Assert a dependency-specific behavior
Choose something the real dependency can prove, such as a constraint or transaction outcome. Merely checking that a connection opens proves availability, not the application's important integration behavior.
Define fixture ownership explicitly
Document which fixture owns process shutdown, which owns data cleanup, and whether parallel workers can collide. Close application pools before destroying their database so cleanup does not create misleading failures.
Preserve a clean-environment run
Keep a route to run the suite without old containers, cached schema state, or developer-specific environment variables. This exposes accidental dependencies that a long-lived local setup can hide.
Common Mistakes
Bad: Replace readiness with an ever-longer fixed sleep.
Correct: Wait for a bounded, service-specific signal and retain the logs needed to explain why it was never reached.
Bad: Share a database between tests and rely on execution order.
Correct: Give each test a data boundary, then verify that independent and parallel execution preserve the result.
Bad: Claim a container test proves parity with a managed production service.
Correct: List the actual version, configuration, extensions, and failure modes covered; verify managed-service behavior separately.
FAQ
Does Testcontainers replace unit tests?
No. Keep fast tests for pure decisions and use container-backed tests where real dependency behavior matters. A service process adds setup and failure modes that a pure function test does not need.
Should every test start a new container?
Use a fresh container when it provides useful isolation at an acceptable cost. Shared containers can work with deliberate data separation. Measure startup and migration costs before choosing a suite-wide rule.
Can tests run without a local Docker engine?
A compatible execution environment still needs to be available through a supported configuration or service. The client library alone cannot create containers without a reachable runtime. Verify support for the selected language library and CI provider.
Is an in-memory database always a bad substitute?
No. It may be appropriate for a different question. When the assertion depends on PostgreSQL semantics, using PostgreSQL avoids silently testing a different engine's behavior.
Related Topics
- Integration Testing — Choose the dependency boundary to verify.
- Pytest — Organize fixtures and meaningful assertions.
- Spring Boot Testing — Connect service tests with JVM application infrastructure.
- Test Data and Environment Management — Design independent test state.
- PostgreSQL — Understand the engine used in the example.