testcontainers-usage

IvanTorresEdge's avatarfrom IvanTorresEdge

Docker-based testing with testcontainers. Use when running tests with real databases.

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 1, 2026

When & Why to Use This Skill

This Claude skill enables developers to implement high-fidelity integration testing using Testcontainers and Docker. It provides standardized patterns for spinning up isolated, real-world database environments (such as PostgreSQL and Redis) during the test lifecycle. By moving away from database mocking, it ensures that application code is validated against actual production-like dependencies, significantly improving test reliability and catching environment-specific bugs early in the development cycle.

Use Cases

  • Isolated Database Testing: Automatically provisioning a fresh PostgreSQL container for each test run to ensure a clean state and prevent cross-test data interference.
  • CI/CD Pipeline Automation: Integrating Docker-based testing into GitHub Actions or other CI platforms to validate code changes against real infrastructure before deployment.
  • Database-Specific Feature Validation: Testing complex SQL queries, migrations, and database triggers that cannot be accurately simulated with in-memory databases or mocks.
  • Multi-Service Orchestration: Coordinating multiple containers (e.g., a database and a cache layer like Redis) to perform comprehensive integration tests for microservices.
  • Local Development Acceleration: Using reusable container configurations to speed up local test execution while maintaining environment parity with production.
nametestcontainers-usage
descriptionDocker-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

  1. Single fork mode - Run container tests serially
  2. Adequate timeouts - Container startup takes time
  3. Clean between tests - Delete data, not container
  4. Reuse for dev - Speed up local development
  5. Fresh for CI - Always start new in CI
  6. 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