Test Environment Management: Docker + Testcontainers for Stable CI Pipelines
Your tests pass locally but fail in CI. The root cause is almost never the test — it is the environment. Docker and Testcontainers solve this by giving every test run an identical, disposable environment.
Why Environment Instability Kills CI
- Different database versions between local and CI
- Shared test data that tests modify in unpredictable order
- Third-party services that are unavailable or rate-limited in CI
- OS-level differences (macOS local vs Linux CI runner)
Testcontainers: Disposable Infrastructure in Code
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { test, expect } from '@playwright/test';
let container;
let dbUrl;
test.beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
dbUrl = container.getConnectionUri();
// Seed test data
await seedDatabase(dbUrl);
});
test.afterAll(async () => {
await container.stop();
});
test('user creation persists to database', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test', email: 'test@test.com' }
});
expect(response.status()).toBe(201);
// Verify directly in the disposable database
const users = await queryDb(dbUrl, 'SELECT * FROM users');
expect(users).toHaveLength(1);
});
Docker Compose for Full Stack Testing
version: '3.8'
services:
app:
build: .
environment:
DATABASE_URL: postgres://test:test@db:5432/testdb
REDIS_URL: redis://cache:6379
depends_on:
db: { condition: service_healthy }
cache: { condition: service_started }
db:
image: postgres:16
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
playwright:
image: mcr.microsoft.com/playwright:v1.59.0
command: npx playwright test
depends_on:
app: { condition: service_started }
volumes:
- ./test-results:/app/test-results
