Ravindra BagaleCourses & study guides मराठी

Chapter 56: Docker CI/CD with Jenkins and ECR, Deploy to Amazon ECS, Troubleshooting, Best Practices and Interview Questions

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.

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.

Test startspytest, JUnit or JestLibrary asks Dockerstart postgres:16-alpineWait until readyport open and log lineTest runsagainst the real databaseContainer removednothing left behind
Picture 1: how Testcontainers works

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.

Session fixtureone postgres:16-alpineReal migrationsalembic upgrade headRollback per testclean isolationtmpfs and fsync offfast testsCI with Dockersame tests everywhere
Picture 2: Kavya's setup

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.

Ravindra Bagale's Tip

Pin the Testcontainers image to the same major version as production, and update both together.

Lab: try at home

  1. Install Testcontainers for your language.
  2. Write a first test that runs select 1.
  3. Add a session fixture.
  4. Apply your real migrations in the fixture.
  5. Add a rollback fixture and run tests in random order.
  6. Run the tests in Jenkins on a Docker agent.
  7. 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

  1. Create a Jenkins pipeline that builds, scans and pushes to ECR.
  2. Deploy the image to ECS Fargate and update it once.
  3. Break a container on purpose and fix it with the playbook.
  4. Shrink one image by at least 50%.
  5. Answer the interview questions aloud without notes.
  6. Find a DNS bug in a distroless container using netshoot.
  7. Run a Docker Hub pull-through mirror and prove the cache works.
  8. Free disk with safe prunes and add log limits.
  9. Reach a host service from a container on Linux with host-gateway.
  10. 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.

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.