The everyday loop: start, connect, tear down
Once the mental model clicks, the day-to-day is a tight loop you'll repeat for every integration test file: declare a container, start it, ask it for the connection details, point your code at those details, and let the library tear it down. The single thing beginners get wrong is hardcoding a port. Let's kill that habit first, because it's the heart of how Testcontainers works.
The dynamic port is the whole trick
Postgres listens on 5432 inside the container. But Testcontainers does not publish it on your host's 5432. It maps the container's 5432 to a random free port on your machine, something like 49173, different on every run. This is deliberate: it's why you can run ten containers at once without collisions and why CI never trips over a port that's already taken. You never hardcode the port. You ask the container what port it got.
=
# postgresql+psycopg2://test:test@localhost:49173/test
What just happened: the container started, mapped Postgres's internal 5432 to a random host port, and get_connection_url() handed back the full address including that port, so your code connects to the right place without you ever naming a number.
The shape is identical across languages. In Java you call container.getJdbcUrl(); in Go you call container.ConnectionString(ctx); in Node you read container.getMappedPort(5432). Same idea every time: the library knows the real port, so you ask it.
Hardcoding
localhost:5432is the number-one Testcontainers mistake. It accidentally works on your laptop if you happen to have a local Postgres on 5432, then fails everywhere else, or worse, your test silently talks to your real local database. Always read the mapped port.
The full loop, start to finish
Here's a complete, realistic test in Python so you can see every step. The with block is doing the lifecycle work: start on enter, stop and delete on exit, even if the test throws.
=
=
# run your real schema, not a mock
assert ==
What just happened: a real Postgres started, you created a real table with a real UNIQUE constraint, inserted and read back a real row, and when the with block ended the container was destroyed, leaving nothing behind.
Notice what this test would now catch that a mock wouldn't: insert two rows with the same email and Postgres rejects the second one for real. That's the constraint actually firing, not your imagination of it.
Start the container once per suite, not per test
Booting a container takes a second or two. If you start a fresh one for every single test, your suite crawls. The standard move is to start the container once for the whole test file (or session), and reset data between tests instead of restarting the container. Reset is cheap; restart is not.
SLOW (don't): FAST (do):
test_a -> start container start container ONCE
test_a -> stop test_a -> TRUNCATE tables
test_b -> start container test_b -> TRUNCATE tables
test_b -> stop test_c -> TRUNCATE tables
test_c -> start container stop container ONCE
...
What just happened: the fast version pays the startup cost a single time and clears data with a quick TRUNCATE between tests, so the suite stays fast while every test still starts from a clean slate.
In pytest you'd put the container in a session- or module-scoped fixture; in JUnit you'd mark the container static with @Container; in Go you start it in TestMain. Same goal: one slow startup, many fast tests.
Containers other than databases
The same loop works for anything with a Docker image. Redis, Kafka, RabbitMQ, Elasticsearch, even a real HTTP service, all follow declare-start-read-port-teardown. When there's no dedicated module for your image, there's a generic container you point at any image and tell which port to wait for.
=
=
# 127.0.0.1 49201
What just happened: a generic Redis container started, you waited until its log said it was ready (so you don't connect too early), and then read the mapped host and port to connect, the same pattern as the Postgres module but spelled out by hand.
That wait_for_logs line is important: a container being started is not the same as the service inside it being ready. The dedicated modules bake in a sensible wait strategy for you; with the generic container you specify your own. More on readiness traps in Phase 3.
In the wild
Most teams split fast unit tests from slower Testcontainers-backed integration tests so the quick feedback loop stays quick, then run both in CI. For how those layers fit together in a pipeline, see /guides/testing-in-ci.
[
{
"q": "Why should you never hardcode localhost:5432 in a Testcontainers test?",
"choices": [
"Postgres doesn't use 5432",
"The container maps the internal port to a random host port, so you must read the mapped port",
"Docker blocks port 5432",
"It's slower than a random port"
],
"answer": 1,
"explain": "Testcontainers maps the container port to a random free host port to avoid collisions; you ask the container for the actual port."
},
{
"q": "What's the recommended way to keep a Testcontainers suite fast?",
"choices": [
"Start a fresh container for every test",
"Mock the container",
"Start the container once per suite and reset data (e.g. TRUNCATE) between tests",
"Disable the wait strategy"
],
"answer": 2,
"explain": "Container startup is the slow part; start once and clear data between tests so each test is still isolated but the suite stays fast."
},
{
"q": "When using the generic container for an image without a dedicated module, what extra step matters most?",
"choices": [
"Hardcoding the port",
"Specifying a wait strategy so you connect only after the service is actually ready",
"Disabling Docker",
"Running it as root"
],
"answer": 1,
"explain": "A started container isn't necessarily a ready service; the generic container needs you to define when it's ready (e.g. wait_for_logs)."
}
]
← Phase 1: Why mocks lie | Overview | Phase 3: Production reality →
Check your understanding 3 questions
1. Why should you never hardcode localhost:5432 in a Testcontainers test?
2. What's the recommended way to keep a Testcontainers suite fast?
3. When using the generic container for an image without a dedicated module, what extra step matters most?