56.11 Integration tests with Testcontainers
Why: the problem
Suppose we are at Freshworks and our order service passes every unit test, then fails in staging because a SQL query behaves differently on real Postgres than on the in-memory H2 or SQLite we used in tests. Mocks and fake databases lie. The fix is to test against the real thing. But a shared test database is slow, flaky and full of other people's data. Testcontainers is a library, for Java, Python, Go, Node.js, .NET and more, that starts real Docker containers from inside our test code: Postgres, Kafka, Redis, LocalStack, anything. Each test run gets a fresh, throwaway container on a random port, and the library waits until it is ready and removes it at the end. Our tests become real integration tests that run the same on a laptop and in CI. In this lesson we write Python and Java tests against real Postgres and Redis, wire them into CI, and learn the speed tricks.
What: every term in plain English
- Testcontainers — Library that controls Docker from tests to start real dependencies.
- Module — Ready-made container class: PostgresContainer, KafkaContainer, RedisContainer.
- Random host port — Mapped port chosen by Docker; tests ask the container for it.
- Wait strategy — How the library decides the container is ready: log line, port, HTTP.
- Ryuk — Helper container that removes test containers even if the test crashes.
- Fixture — pytest setup shared by tests; scope=session reuses one container.
- Reusable containers — Opt-in mode that keeps containers between runs on a laptop.
- Testcontainers Cloud — Docker's hosted option to run test containers remotely.
How: step by step
- Install the library. pip or Maven dependency.
- Start a container in a fixture. with PostgresContainer(...).
- Get the URL. get_connection_url().
- Run migrations and tests. Real SQL, real behaviour.
- Clean up automatically. Context manager and Ryuk.
- Run in CI. Any runner with Docker.
- Speed up. Session scope, pinned small images, pre-pull.
Example 1: Python: test a repository against real Postgres
Goal. Python: test a repository against real Postgres.
app/repo.py
import psycopg2
def save_order(conn, customer, amount):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO orders(customer, amount) VALUES (%s, %s) "
"ON CONFLICT (customer) DO UPDATE SET amount = orders.amount + EXCLUDED.amount "
"RETURNING amount",
(customer, amount),
)
conn.commit()
return cur.fetchone()[0]
tests/test_repo.py
import psycopg2, pytest
from testcontainers.postgres import PostgresContainer
from app.repo import save_order
@pytest.fixture(scope="session")
def pg():
with PostgresContainer("postgres:16-alpine") as c:
yield c
@pytest.fixture
def conn(pg):
url = pg.get_connection_url().replace("postgresql+psycopg2", "postgresql")
conn = psycopg2.connect(url)
with conn.cursor() as cur:
cur.execute("DROP TABLE IF EXISTS orders")
cur.execute("CREATE TABLE orders(customer text PRIMARY KEY, amount numeric NOT NULL)")
conn.commit()
yield conn
conn.close()
def test_upsert_adds_amounts(conn):
assert save_order(conn, "asha", 100) == 100
assert save_order(conn, "asha", 50) == 150
Commands
pytest -q tests/test_repo.py
docker ps --format '{{.Image}} {{.Ports}}'
What each line does
ON CONFLICT ... DO UPDATE— Postgres-specific SQL that SQLite or H2 may treat differently.PostgresContainer('postgres:16-alpine')— Pinned, small image, same major version as production.scope='session'— One container for the whole test run: fast.get_connection_url()— Includes the random host port.Fresh table per test— Isolation without a new container.docker ps during run— Shows the test Postgres and the Ryuk container.
Green output
. [100%]
1 passed in 4.21s
postgres:16-alpine 0.0.0.0:32771->5432/tcp
testcontainers/ryuk:0.8.1 0.0.0.0:32770->8080/tcp
Expected result. The test ran against real Postgres in about 4 seconds and the containers were removed afterwards.
Example 2: Java with JUnit 5: Postgres and Redis together
Goal. Java with JUnit 5: Postgres and Redis together.
build.gradle.kts (excerpt)
dependencies {
testImplementation("org.testcontainers:postgresql:1.20.3")
testImplementation("org.testcontainers:junit-jupiter:1.20.3")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.3")
testImplementation("redis.clients:jedis:5.2.0")
}
tasks.test { useJUnitPlatform() }
OrderCacheIT.java
@Testcontainers
class OrderCacheIT {
@Container
static PostgreSQLContainer pg = new PostgreSQLContainer("postgres:16-alpine");
@Container
static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:7.4-alpine"))
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));
@Test
void cachesOrderAfterFirstRead() throws Exception {
var repo = new OrderRepo(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword());
var cache = new OrderCache(redis.getHost(), redis.getMappedPort(6379), repo);
repo.insert("ord-1", 499);
assertEquals(499, cache.get("ord-1"));
repo.updateAmount("ord-1", 999);
assertEquals(499, cache.get("ord-1"));
}
}
Commands
./gradlew test --tests OrderCacheIT
What each line does
@Testcontainers / @Container— JUnit starts and stops containers around the class.static— One container per class, not per test.getJdbcUrl()— Ready JDBC URL with the random port.withExposedPorts(6379)— Redis port; getMappedPort gives the host port.waitingFor log message— Test starts only when Redis is ready.Second read is cached— Real Redis behaviour, not a mock.
Green output
OrderCacheIT > cachesOrderAfterFirstRead() PASSED
BUILD SUCCESSFUL in 9s
Expected result. Two real dependencies, no shared environment, and the same test runs on every laptop and CI agent.
Example 3: Run in CI and make it fast
Goal. Run in CI and make it fast.
ci.yml (GitLab CI)
integration-tests:
image: python:3.12-slim
services:
- name: docker:27-dind
alias: docker
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
TESTCONTAINERS_HOST_OVERRIDE: docker
before_script:
- pip install -q "testcontainers[postgres]" pytest psycopg2-binary
script:
- pytest -q tests/
Jenkinsfile (stage)
stage('Integration tests') {
agent { label 'docker' }
steps {
sh 'docker pull -q postgres:16-alpine'
sh 'pip install -q "testcontainers[postgres]" pytest psycopg2-binary'
sh 'pytest -q tests/ --junitxml=reports/it.xml'
}
post { always { junit 'reports/it.xml' } }
}
~/.testcontainers.properties (laptop only)
testcontainers.reuse.enable=true
What each line does
docker:27-dind service— GitLab runners without a host socket use DinD (see 52-12).TESTCONTAINERS_HOST_OVERRIDE— Tells the library the containers are reachable at host docker.Jenkins docker agent— Agent with Docker access runs the same tests.docker pull -q first— Pre-pull so the test timer does not include the download.junit report— Results show in Jenkins.reuse.enable— Keeps containers between local runs; never in CI.
Green output
$ pytest -q tests/
.... [100%]
4 passed in 7.92s
Job succeeded
Expected result. The same tests run in GitLab and Jenkins. Session scope, small pinned images and pre-pulling keep the run under 10 seconds.
Every command, step by step (plain docker and Compose)
-
Is Docker reachable?
docker info --format '{{.ServerVersion}}'What it does: Testcontainers needs a Docker API.
27.3.1 -
Run tests
pytest -q tests/What it does: Starts containers as needed.
4 passed -
See test containers
docker ps --filter label=org.testcontainers=trueWhat it does: Labelled containers.
postgres:16-alpine ... -
Clean leftovers
docker rm -f $(docker ps -aq --filter label=org.testcontainers=true)What it does: After a hard crash.
(ids) -
Disable Ryuk (restricted CI)
export TESTCONTAINERS_RYUK_DISABLED=trueWhat it does: Only if Ryuk cannot run; clean up yourself.
(set) -
Remote Docker
export DOCKER_HOST=tcp://docker:2375What it does: Point at DinD or a remote daemon.
(set) -
Pre-pull
docker pull -q postgres:16-alpine redis:7.4-alpineWhat it does: Faster first test.
docker.io/library/postgres:16-alpine
When it is red 1
docker.errors.DockerException: Error while fetching server API version: Connection refused
Cause. The test process cannot reach Docker.
Fix, step by step
- Start Docker or set DOCKER_HOST.
- In CI, give the job Docker access (socket or DinD).
When it is red 2
Timed out waiting for container port to open (localhost ports: [32771] should be listening)
Cause. Container slow to start, or the host name is wrong in DinD.
Fix, step by step
- Set TESTCONTAINERS_HOST_OVERRIDE=docker in DinD.
- Use a log wait strategy; pre-pull images.
When it is red 3
Could not find a valid Docker environment
Cause. Java library could not detect Docker.
Fix, step by step
- Check docker info works for the same user.
- Set DOCKER_HOST, or use Testcontainers Cloud.
Practice tasks
Easy Task 1: Install
Why: Start.
What to do: Install testcontainers for Python.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
pip install 'testcontainers[postgres]' pytest psycopg2-binaryEasy Task 2: First test
Why: Real DB.
What to do: Run test_repo.py.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
pytest -q tests/test_repo.pyMedium Task 3: Session scope
Why: Speed.
What to do: Share one Postgres across tests.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
@pytest.fixture(scope='session')Medium Task 4: Isolation
Why: Clean.
What to do: Recreate the table per test.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
DROP TABLE IF EXISTS ordersMedium Task 5: Redis
Why: Two deps.
What to do: Add a RedisContainer test.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
from testcontainers.redis import RedisContainerHard Task 6: Java
Why: JUnit.
What to do: Run OrderCacheIT.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
./gradlew test --tests OrderCacheITHard Task 7: Wait strategy
Why: Ready.
What to do: Wait for a log line.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
Wait.forLogMessage(".*Ready.*", 1)Hard Task 8: CI
Why: Pipeline.
What to do: Run tests in GitLab with DinD.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
TESTCONTAINERS_HOST_OVERRIDE: dockerMedium Task 9: Leftovers
Why: Clean.
What to do: List labelled containers.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
docker ps --filter label=org.testcontainers=trueEasy Task 10: Pre-pull
Why: Speed.
What to do: Pre-pull images in CI.
- Install Docker, Python 3.11+ and Java 17+ (for the Java example).
- pip install testcontainers[postgres] pytest psycopg2-binary.
- Run the tests and compare with the solution.
Solution
docker pull -q postgres:16-alpineHomework: try at home
At home, take one repository class that talks to a database, write a Testcontainers test against the real engine, and run it locally and in your CI.
Recap
- Test against real dependencies, not fakes.
- Testcontainers starts throwaway containers from test code.
- Use random ports via the library API.
- Ryuk cleans up even after crashes.
- CI needs a Docker API: socket, DinD or a cloud runner.
- Session scope, pinned small images and pre-pulling keep tests fast.
FAQ
Q. Is Testcontainers only for Java?
No: Java, Python, Go, Node.js, .NET, Rust and more.
Q. Can I test AWS code?
Yes, with the LocalStack module for S3, SQS, DynamoDB and others.
Q. Kafka?
KafkaContainer or the Redpanda module start a real broker.
Q. Does it work with Podman?
Yes, with the Podman socket set as DOCKER_HOST and Ryuk settings adjusted.
Q. Unit tests or integration tests?
Keep fast unit tests; use Testcontainers for code that talks to databases, queues and APIs.
Q. Where are the docs?
testcontainers.com and the module pages for each language.
Q. How is this different from docker compose up before tests?
Compose gives one shared environment that must be started and cleaned by hand; Testcontainers gives each run its own fresh containers, controlled by the test code, with random ports and automatic clean-up.
Interview questions
-
What is Testcontainers?
Model answer: A library that starts real dependencies in Docker from test code with random ports, wait strategies and automatic clean-up.
-
Why prefer it over mocks or in-memory DBs?
Model answer: Real engine behaviour: SQL dialect, constraints, transactions.
-
How do you run Testcontainers in CI?
Model answer: Give the job a Docker API (host socket, DinD, or Testcontainers Cloud) and pre-pull images.
-
What is Ryuk?
Model answer: A sidecar that removes containers, networks and volumes after the test session.
-
How do you keep tests fast?
Model answer: Share containers per session or class, small pinned images, reuse locally.
-
How do you avoid port clashes?
Model answer: Never fix host ports; use the mapped random port from the API.
Quick quiz
Q1. Why not SQLite or H2 for tests?
Different SQL behaviour from production.
Q2. How does the test find the port?
Ask the container: get_connection_url or getMappedPort.
Q3. What removes containers after a crash?
Ryuk.
Q4. How to make tests fast?
Session scope, small pinned images, pre-pull.
Q5. What does CI need?
Access to a Docker API.
Samajla ka?
Lab
- Create a Jenkins pipeline that builds, scans and pushes to ECR.
- Deploy the image to ECS Fargate and update it once.
- Break a container on purpose and fix it with the playbook.
- Shrink one image by at least 50%.
- Answer the interview questions aloud without notes.
- Find a DNS bug in a distroless container using netshoot.
- Run a Docker Hub pull-through mirror and prove the cache works.
- Free disk with safe prunes and add log limits.
- Reach a host service from a container on Linux with host-gateway.
- Write one Testcontainers test against real Postgres.
Practice task
Build an end-to-end flow: commit, Jenkins build, Trivy scan, ECR push, ECS rolling deploy. Write down every command and output.
Ravindra Bagale's Tip
Interview madhe command chalvun dakhavlat tar jast impact padto. Roj ek lab kara.
In short
- Tag images with the commit and push with --password-stdin.
- ECS pulls with the execution role and injects secrets.
- Troubleshoot in order: ps, logs, inspect, exec, events.
- Shrink images with slim bases and multi-stage.
- Follow the best practices checklist for every service.
Revision card
Interview card