56.12 Testcontainers: integration tests against a real Postgres
Friends, unit tests with fake databases pass, and then the app breaks in production because real PostgreSQL behaves differently: a unique constraint, a JSON operator, a migration that fails. Testcontainers fixes this. Your test code starts a real Postgres in Docker, runs the test against it, and throws it away afterwards. Today, step by step: how it works, the first Python test, fixtures, migrations, isolation between tests, Java and Node versions, running in CI, speed tricks and cleanup.
मित्रांनो, fake databases वापरून unit tests pass होतात, आणि मग production मध्ये app तुटतं कारण खरा PostgreSQL वेगळा वागतो: unique constraint, JSON operator, fail होणारी migration. Testcontainers हे सोडवतं. तुमचा test code Docker मध्ये खरा Postgres सुरू करतो, त्यावर test चालवतो, आणि नंतर तो फेकून देतो. आज step by step: हे कसं काम करतं, पहिली Python test, fixtures, migrations, tests मधलं isolation, Java आणि Node versions, CI मध्ये चालवणं, speed tricks आणि cleanup.
दोस्तों, fake databases के साथ unit tests pass होते हैं, और फिर production में app टूट जाता है क्योंकि असली PostgreSQL अलग बर्ताव करता है: unique constraint, JSON operator, fail होने वाली migration. Testcontainers इसे ठीक करता है. आपका test code Docker में असली Postgres शुरू करता है, उस पर test चलाता है, और बाद में उसे फेंक देता है. आज step by step: यह कैसे काम करता है, पहला Python test, fixtures, migrations, tests के बीच isolation, Java और Node versions, CI में चलाना, speed tricks और cleanup.
1. Start from a story
At CRED, the rewards team tested their repository code with SQLite in memory. All 400 tests were green.
In production, a query using ON CONFLICT with a JSONB column failed, because SQLite does not support the same syntax. The bug reached users for two hours.
An engineer named Kavya replaced SQLite with Testcontainers. Each test run now starts postgres:16-alpine, the same major version as production, applies the real migrations and runs the tests. The run takes 40 seconds longer, and that class of bug has not returned.
Today we build Kavya's setup.
2. First steps
Step 1: What you need
Why: Docker plus a library.
What: Testcontainers is a library for your test language. It talks to the Docker daemon, so Docker must be running where the tests run.
Example 1: Docker is up
Type this:
docker info --format '{{.ServerVersion}}'
You will see:
27.3.1
What it means: The daemon is reachable.
Example 2: Install for Python
Type this:
pip install 'testcontainers[postgres]' psycopg[binary] pytest
You will see:
Successfully installed testcontainers-4.8.2 ...
What it means: Library plus a Postgres driver.
Example 3: Check
Type this:
python -c 'import testcontainers; print("ok")'
You will see:
ok
What it means: Ready.
Step 2: The first test
Why: Start, connect, assert.
What: PostgresContainer starts the image, waits until it is ready, and gives you a connection URL with a random host port, so tests never clash.
Example 4: test_first.py
Type this:
from testcontainers.postgres import PostgresContainer
import psycopg
def test_select_one():
with PostgresContainer('postgres:16-alpine', driver=None) as pg:
with psycopg.connect(pg.get_connection_url()) as c:
assert c.execute('select 1').fetchone()[0] == 1
You will see:
(file)
What it means: The container lives only inside the with block.
Example 5: Run
Type this:
pytest -q test_first.py
You will see:
1 passed in 4.21s
What it means: A real Postgres, in about four seconds.
Example 6: Nothing left
Type this:
docker ps -a --filter ancestor=postgres:16-alpine -q | wc -l
You will see:
0
What it means: Removed after the test.
Step 3: What happened behind the scenes
Why: See the containers.
What: While a test runs you can watch the containers it creates. Testcontainers also starts a small helper called Ryuk that removes leftovers if the test process crashes.
Example 7: Watch
Type this:
docker ps --format '{{.Image}} {{.Ports}}'
You will see:
postgres:16-alpine 0.0.0.0:55012-5432/tcp
testcontainers/ryuk:0.11.0 0.0.0.0:55011-8080/tcp
What it means: Random port 55012 for Postgres.
Example 8: Labels
Type this:
docker inspect --format '{{index .Config.Labels "org.testcontainers"}}' $(docker ps -q --filter ancestor=postgres:16-alpine)
You will see:
true
What it means: Marked as a test container.
Example 9: Kill the test, Ryuk cleans
Type this:
sleep 15 && docker ps -q --filter label=org.testcontainers=true | wc -l
You will see:
0
What it means: Even a crashed run leaves nothing.
3. Real project setup
Step 4: A session fixture
Why: Start once, use in many tests.
What: Starting a container per test is slow. A pytest fixture with session scope starts one Postgres for the whole run.
Example 10: conftest.py
Type this:
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope='session')
def pg_url():
with PostgresContainer('postgres:16-alpine', driver=None) as pg:
yield pg.get_connection_url()
You will see:
(file)
What it means: One container for all tests.
Example 11: Use it
Type this:
def test_insert(pg_url):
with psycopg.connect(pg_url) as c:
c.execute('create table if not exists t(x int)')
c.execute('insert into t values (1)')
You will see:
(test)
What it means: Tests just ask for pg_url.
Example 12: Run many
Type this:
pytest -q tests/
You will see:
58 passed in 9.84s
What it means: One start, 58 tests.
Step 5: Apply the real migrations
Why: Test the schema you ship.
What: Run the same migration tool you use in production, such as Alembic or Flyway, inside the fixture. A broken migration now fails the test run.
Example 13: Fixture with Alembic
Type this:
@pytest.fixture(scope='session')
def db(pg_url):
import subprocess, os
env = dict(os.environ, DATABASE_URL=pg_url)
subprocess.run(['alembic', 'upgrade', 'head'], check=True, env=env)
return pg_url
You will see:
(conftest.py)
What it means: Real schema before any test.
Example 14: Run
Type this:
pytest -q tests/ 2>&1 | tail -2
You will see:
INFO [alembic.runtime.migration] Running upgrade 0041 -> 0042, add rewards jsonb
58 passed in 11.02s
What it means: Migrations applied.
Example 15: A bad migration
Type this:
pytest -q tests/ 2>&1 | grep -m1 Error
You will see:
psycopg.errors.DuplicateColumn: column "points" of relation "rewards" already exists
What it means: Caught in tests, not in production.
Step 6: The bug SQLite missed
Why: Test Postgres features.
What: Now the test runs the real ON CONFLICT with JSONB from the story and proves it works.
Example 16: The test
Type this:
def test_upsert_reward(db):
with psycopg.connect(db) as c:
sql = "insert into rewards(user_id, meta) values (7, '{\"tier\":\"gold\"}') on conflict (user_id) do update set meta = rewards.meta || excluded.meta"
c.execute(sql); c.execute(sql)
assert c.execute('select count(*) from rewards').fetchone()[0] == 1
You will see:
(test)
What it means: Runs twice, still one row.
Example 17: Run
Type this:
pytest -q -k upsert
You will see:
1 passed in 5.02s
What it means: Real Postgres behaviour checked.
Example 18: Version matches production
Type this:
pytest -q -k version -s
You will see:
server_version 16.4
What it means: Same major version as production.
Step 7: Isolation between tests
Why: No test should see another's data.
What: With one shared database, wrap each test in a transaction and roll it back, or truncate tables after each test.
Example 19: Rollback fixture
Type this:
@pytest.fixture
def conn(db):
with psycopg.connect(db) as c:
c.execute('begin')
yield c
c.rollback()
You will see:
(conftest.py)
What it means: Every test starts clean.
Example 20: Two tests, same id
Type this:
pytest -q -k 'user_seven'
You will see:
2 passed in 0.31s
What it means: No duplicate key between tests.
Example 21: Random order
Type this:
pip install pytest-randomly >/dev/null && pytest -q tests/ | tail -1
You will see:
58 passed in 10.77s
What it means: Order does not matter: true isolation.
4. Other languages
Step 8: Java with JUnit 5
Why: The original Testcontainers.
What: In Java, the @Testcontainers and @Container annotations manage the lifecycle. Spring Boot can read the URL with @ServiceConnection.
Example 22: The test
Type this:
@Testcontainers
class RewardRepoTest {
@Container
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");
@Test void connects() throws Exception {
try (var c = DriverManager.getConnection(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword())) {
assertTrue(c.isValid(2));
}
}
}
You will see:
(RewardRepoTest.java)
What it means: static means one container for the class.
Example 23: Run
Type this:
./mvnw -q test -Dtest=RewardRepoTest
You will see:
Tests run: 1, Failures: 0, Errors: 0
What it means: Passes.
Example 24: Dependency
Type this:
mvn dependency:tree | grep testcontainers
You will see:
org.testcontainers:postgresql:jar:1.20.3:test
What it means: Test scope only.
Step 9: Node with Jest
Why: Same idea in JavaScript.
What: The @testcontainers/postgresql package starts the container in beforeAll and stops it in afterAll.
Example 25: The test
Type this:
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Client } = require('pg');
let pg;
beforeAll(async () => { pg = await new PostgreSqlContainer('postgres:16-alpine').start(); }, 60000);
afterAll(async () => { await pg.stop(); });
test('select 1', async () => {
const c = new Client({ connectionString: pg.getConnectionUri() });
await c.connect();
expect((await c.query('select 1 as x')).rows[0].x).toBe(1);
await c.end();
});
You will see:
(reward.test.js)
What it means: Long timeout for the first image pull.
Example 26: Run
Type this:
npx jest reward.test.js
You will see:
Tests: 1 passed, 1 total
What it means: Works.
Example 27: Install
Type this:
npm i -D @testcontainers/postgresql pg jest
You will see:
added 112 packages
What it means: Dev dependencies only.
5. CI, speed and cleanup
Step 10: Run in CI
Why: The runner needs Docker.
What: In Jenkins, use an agent with Docker available. In a container-based agent, mount the Docker socket or use a Docker-in-Docker service, then run tests as usual.
Example 28: Jenkins stage
Type this:
stage('Integration tests') {
agent { label 'docker' }
steps { sh 'pip install -r requirements-dev.txt && pytest -q tests/' }
}
You will see:
(Jenkinsfile)
What it means: An agent that has Docker.
Example 29: Log
Type this:
pytest -q tests/ | tail -1
You will see:
58 passed in 14.30s
What it means: Slower on first run because of the pull.
Example 30: Pre-pull the image
Type this:
docker pull -q postgres:16-alpine
You will see:
docker.io/library/postgres:16-alpine
What it means: Put this in the agent image or a cache to save time.
Step 11: Speed tricks
Why: Keep tests fast.
What: Use alpine images, one container per session, a pull-through mirror for images, and tmpfs for the data folder so Postgres writes to memory.
Example 31: tmpfs data
Type this:
pg = PostgresContainer('postgres:16-alpine', driver=None).with_kwargs(tmpfs={'/var/lib/postgresql/data': 'rw'})
You will see:
(conftest.py)
What it means: No disk writes.
Example 32: Fewer fsyncs
Type this:
pg = pg.with_command('postgres -c fsync=off -c synchronous_commit=off')
You will see:
(conftest.py)
What it means: Fine for throwaway tests, never for production.
Example 33: Compare
Type this:
pytest -q tests/ | tail -1
You will see:
58 passed in 6.12s
What it means: From 11 seconds to 6.
Step 12: Cleanup and troubleshooting
Why: When something is left or fails.
What: If tests cannot find Docker, check DOCKER_HOST and the socket. If containers are left after a killed CI job, remove them by the testcontainers label.
Example 34: No Docker
Type this:
pytest -q 2>&1 | grep -m1 -i docker
You will see:
docker.errors.DockerException: Error while fetching server API version: Connection refused
What it means: The daemon is not reachable from the tests.
Example 35: Check the socket
Type this:
ls -l /var/run/docker.sock && docker version --format '{{.Server.Version}}'
You will see:
srw-rw---- 1 root docker 0 ... /var/run/docker.sock
27.3.1
What it means: Socket exists; user must be in the docker group.
Example 36: Remove leftovers
Type this:
docker rm -f $(docker ps -aq --filter label=org.testcontainers=true) 2>/dev/null; docker ps -aq --filter label=org.testcontainers=true | wc -l
You will see:
0
What it means: Only test containers are touched.
6. Common mistakes and how Kavya avoids them
Mistake one: using latest as the image tag. She pins the same major version as production.
Mistake two: a new container for every single test. She uses a session fixture plus rollback.
Mistake three: hardcoding port 5432. She always reads the URL from the container, which uses a random port.
Mistake four: tests that depend on each other's data. She runs them in random order to prove they do not.
Check yourself
Q1. What does Testcontainers do?
Starts real services in Docker for a test and removes them after.
Q2. Why not hardcode port 5432?
The host port is random; read it from the container.
Q3. How do you keep tests isolated with one database?
Roll back a transaction after each test.
Q4. What does Ryuk do?
Removes test containers if the test process dies.
Friends, test against the real database version you run in production: start it with Testcontainers, apply your real migrations, isolate each test, and make it fast with one container per session and tmpfs. Did you understand? Let us try it once more. Take one repository class in your project, write three tests with Testcontainers, and break a migration on purpose to see the tests catch it.
मित्रांनो, production मध्ये चालवता त्याच खऱ्या database version वर test करा: Testcontainers ने तो सुरू करा, खऱ्या migrations लावा, प्रत्येक test isolate करा, आणि session मागे एक container आणि tmpfs ने तो fast करा. तुमच्या project मधला एक repository class घ्या, Testcontainers सह तीन tests लिहा, आणि मुद्दाम एक migration तोडून tests ती पकडतात का ते पाहा.
दोस्तों, production में जो असली database version चलाते हैं उसी पर test कीजिए: Testcontainers से उसे शुरू कीजिए, असली migrations लगाइए, हर test isolate कीजिए, और session पर एक container और tmpfs से उसे fast बनाइए. अपने project की एक repository class लीजिए, Testcontainers के साथ तीन tests लिखिए, और जानबूझकर एक migration तोड़कर देखिए कि tests उसे पकड़ते हैं या नहीं.
Ravindra Bagale's Tip
Pin the Testcontainers image to the same major version as production, and update both together.
Testcontainers image production च्या same major version वर pin करा, आणि दोन्ही एकत्र update करा.
Testcontainers image को production के same major version पर pin कीजिए, और दोनों को साथ में update कीजिए.
Lab: try at home
- Install Testcontainers for your language.
- Write a first test that runs select 1.
- Add a session fixture.
- Apply your real migrations in the fixture.
- Add a rollback fixture and run tests in random order.
- Run the tests in Jenkins on a Docker agent.
- Add tmpfs and compare the run time.
Recap
- Testcontainers starts real services for tests.
- Read the random port from the container.
- Session fixture plus rollback per test.
- Apply real migrations; pin the production version.
- CI needs Docker; Ryuk and labels handle cleanup.
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
In an interview, running a command and showing the output makes a far bigger impact than words. Do one lab every day.
Interview मध्ये command चालवून output दाखवलात तर शब्दांपेक्षा खूप जास्त impact पडतो. रोज एक lab करा.
Interview में command चलाकर output दिखाने का असर शब्दों से कहीं ज़्यादा होता है. रोज़ एक lab कीजिए.
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.