| name | testcontainers-usage |
| description | Docker-based testing with testcontainers. Use when running tests with real databases. |
Testcontainers Usage Skill
This skill covers testcontainers for isolated integration testing with Docker.
When to Use
Use this skill when:
- Need isolated database per test run
- Testing against real database
- Running CI/CD pipeline tests
- Testing database-specific features
Core Principle
REAL DEPENDENCIES - Test against real databases in containers. No mocking database behavior.
Installation
npm install -D @testcontainers/postgresql testcontainers
Basic Setup
// tests/setup/containers.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
let container: StartedPostgreSqlContainer;
let prisma: PrismaClient;
export async function startDatabase(): Promise<{
container: StartedPostgreSqlContainer;
prisma: PrismaClient;
connectionString: string;
}> {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
const connectionString = container.getConnectionUri();
// Set environment variable for Prisma
process.env.DATABASE_URL = connectionString;
// Run migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: connectionString },
stdio: 'inherit',
});
// Create Prisma client
prisma = new PrismaClient({
datasources: {
db: { url: connectionString },
},
});
await prisma.$connect();
return { container, prisma, connectionString };
}
export async function stopDatabase(): Promise<void> {
if (prisma) {
await prisma.$disconnect();
}
if (container) {
await container.stop();
}
}
export function getPrisma(): PrismaClient {
return prisma;
}
Vitest Configuration
// vitest.container.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['**/*.container.test.ts'],
testTimeout: 120000,
hookTimeout: 60000,
pool: 'forks',
poolOptions: {
forks: {
singleFork: true, // Run serially for container tests
},
},
globalSetup: './tests/setup/container-setup.ts',
},
});
Global Setup
// tests/setup/container-setup.ts
import { startDatabase, stopDatabase } from './containers';
export async function setup(): Promise<void> {
console.log('Starting test database container...');
await startDatabase();
console.log('Test database ready');
}
export async function teardown(): Promise<void> {
console.log('Stopping test database container...');
await stopDatabase();
console.log('Container stopped');
}
Per-Test Container
// tests/setup/per-test-container.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
export async function withDatabase<T>(
testFn: (prisma: PrismaClient) => Promise<T>
): Promise<T> {
const container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
const connectionString = container.getConnectionUri();
// Run migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: connectionString },
stdio: 'pipe',
});
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
try {
await prisma.$connect();
return await testFn(prisma);
} finally {
await prisma.$disconnect();
await container.stop();
}
}
Integration Test Example
// src/services/__tests__/user.container.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { startDatabase, stopDatabase, getPrisma } from '../../../tests/setup/containers';
import { UserService } from '../user';
describe('UserService with testcontainers', () => {
let prisma: PrismaClient;
let userService: UserService;
beforeAll(async () => {
const db = await startDatabase();
prisma = db.prisma;
userService = new UserService(prisma);
}, 120000); // 2 minute timeout for container startup
afterAll(async () => {
await stopDatabase();
});
beforeEach(async () => {
// Clean up between tests
await prisma.user.deleteMany();
});
it('creates a user', async () => {
const user = await userService.create({
email: 'test@example.com',
name: 'Test User',
password: 'password123',
});
expect(user.id).toBeDefined();
expect(user.email).toBe('test@example.com');
});
it('finds user by email', async () => {
await userService.create({
email: 'find@example.com',
name: 'Find Me',
password: 'password123',
});
const found = await userService.findByEmail('find@example.com');
expect(found).not.toBeNull();
expect(found?.name).toBe('Find Me');
});
it('returns null for non-existent user', async () => {
const found = await userService.findByEmail('nonexistent@example.com');
expect(found).toBeNull();
});
it('updates user', async () => {
const user = await userService.create({
email: 'update@example.com',
name: 'Original Name',
password: 'password123',
});
const updated = await userService.update(user.id, { name: 'New Name' });
expect(updated.name).toBe('New Name');
});
it('deletes user', async () => {
const user = await userService.create({
email: 'delete@example.com',
name: 'Delete Me',
password: 'password123',
});
await userService.delete(user.id);
const found = await userService.findById(user.id);
expect(found).toBeNull();
});
});
Multiple Containers
// tests/setup/multi-containers.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { GenericContainer, StartedTestContainer } from 'testcontainers';
interface TestContainers {
postgres: StartedPostgreSqlContainer;
redis: StartedTestContainer;
}
export async function startContainers(): Promise<TestContainers> {
// Start containers in parallel
const [postgres, redis] = await Promise.all([
new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.start(),
new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.start(),
]);
// Set environment variables
process.env.DATABASE_URL = postgres.getConnectionUri();
process.env.REDIS_URL = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`;
return { postgres, redis };
}
export async function stopContainers(containers: TestContainers): Promise<void> {
await Promise.all([
containers.postgres.stop(),
containers.redis.stop(),
]);
}
Reusable Container
// tests/setup/reusable-container.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let reusableContainer: StartedPostgreSqlContainer | null = null;
export async function getReusableContainer(): Promise<StartedPostgreSqlContainer> {
if (!reusableContainer) {
reusableContainer = await new PostgreSqlContainer('postgres:16-alpine')
.withReuse()
.start();
}
return reusableContainer;
}
// Note: Reusable containers persist between test runs
// Use for faster local development
// Don't use in CI - start fresh containers there
Test with App
// src/routes/__tests__/users.container.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import supertest from 'supertest';
import { FastifyInstance } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { buildApp } from '../../app';
import { startDatabase, stopDatabase } from '../../../tests/setup/containers';
describe('Users API with real database', () => {
let app: FastifyInstance;
let prisma: PrismaClient;
beforeAll(async () => {
const db = await startDatabase();
prisma = db.prisma;
app = await buildApp();
await app.ready();
}, 120000);
afterAll(async () => {
await app.close();
await stopDatabase();
});
beforeEach(async () => {
await prisma.user.deleteMany();
});
it('creates and retrieves user', async () => {
// Create
const createResponse = await supertest(app.server)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'Password123!',
})
.expect(201);
const userId = createResponse.body.id;
// Retrieve
const getResponse = await supertest(app.server)
.get(`/api/users/${userId}`)
.expect(200);
expect(getResponse.body.email).toBe('test@example.com');
});
});
CI Configuration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
container-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- name: Run container tests
run: npm run test:containers
Best Practices
- Single fork mode - Run container tests serially
- Adequate timeouts - Container startup takes time
- Clean between tests - Delete data, not container
- Reuse for dev - Speed up local development
- Fresh for CI - Always start new in CI
- Parallel containers - Start multiple services together
Notes
- Requires Docker to be running
- First run downloads images (slow)
- Reusable containers speed up local dev
- Container logs available for debugging