AgentSkillsCN

Testcontainers

Testcontainers

SKILL.md

Testcontainers Integration Patterns

Patterns for using @testcontainers/postgresql for backend integration testing with real PostgreSQL databases.

Import Conventions

  • Same folder: Use relative ./ imports (e.g., import { setupTestDatabase } from './database')
  • Parent/other folders: Use @/api/ alias (e.g., import * as schema from '@/api/database/schema')
  • Shared libs: Use package imports (e.g., import type { User } from '@hair-product-scanner/shared')
  • NEVER use ../ - parent imports must use @/api/ alias

Setup

Installation

bash
pnpm add -D @testcontainers/postgresql testcontainers

Basic Container Setup

typescript
// test/setup/database.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import * as schema from '@/api/database/schema';

let container: StartedPostgreSqlContainer;
let pool: Pool;
let db: ReturnType<typeof drizzle>;

export async function setupTestDatabase() {
  // Start PostgreSQL container
  container = await new PostgreSqlContainer().withDatabase('test_db').withUsername('test').withPassword('test').start();

  // Create connection pool
  pool = new Pool({
    connectionString: container.getConnectionUri(),
  });

  // Initialize Drizzle
  db = drizzle(pool, { schema });

  // Run migrations
  await migrate(db, { migrationsFolder: './drizzle' });

  return { db, pool, container };
}

export async function teardownTestDatabase() {
  if (pool) {
    await pool.end();
  }
  if (container) {
    await container.stop();
  }
}

export function getTestDatabase() {
  return db;
}

Test Lifecycle

Global Setup (vitest.config.ts)

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    globalSetup: './test/setup/global-setup.ts',
    setupFiles: ['./test/setup/test-setup.ts'],
  },
});

Global Setup File

typescript
// test/setup/global-setup.ts
import { setupTestDatabase, teardownTestDatabase } from './database';

export async function setup() {
  console.log('Starting test database container...');
  const { db } = await setupTestDatabase();

  // Store connection for tests
  (globalThis as any).__TEST_DB__ = db;

  console.log('Test database ready');
}

export async function teardown() {
  console.log('Stopping test database container...');
  await teardownTestDatabase();
  console.log('Test database stopped');
}

Per-Test Setup

typescript
// test/setup/test-setup.ts
import { beforeEach, afterEach } from 'vitest';
import { getTestDatabase } from './database';
import { sql } from 'drizzle-orm';

beforeEach(async () => {
  const db = getTestDatabase();
  // Truncate all tables before each test
  await db.execute(sql`TRUNCATE TABLE scans, hair_profiles, users CASCADE`);
});

afterEach(async () => {
  // Any cleanup needed
});

Repository Integration Tests

Basic Repository Test

typescript
// scan.repository.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ScanRepository } from './scan.repository';
import { getTestDatabase } from '@/api/test/setup/database';
import { users, scans } from '@/api/database/schema';

describe('ScanRepository Integration', () => {
  let repository: ScanRepository;
  let db: ReturnType<typeof getTestDatabase>;
  let testUserId: string;

  beforeEach(async () => {
    db = getTestDatabase();
    repository = new ScanRepository(db);

    // Create test user
    const [user] = await db.insert(users).values({ email: 'test@example.com', name: 'Test User' }).returning();
    testUserId = user.id;
  });

  describe('create', () => {
    it('creates a new scan', async () => {
      const scanData = {
        userId: testUserId,
        productName: 'Test Shampoo',
        ingredients: 'water, sodium laureth sulfate',
      };

      const result = await repository.create(scanData);

      expect(result.id).toBeDefined();
      expect(result.productName).toBe('Test Shampoo');
      expect(result.userId).toBe(testUserId);
    });
  });

  describe('findById', () => {
    it('returns scan when found', async () => {
      // Arrange - create a scan
      const [scan] = await db
        .insert(scans)
        .values({
          userId: testUserId,
          productName: 'Test Product',
          ingredients: 'water',
        })
        .returning();

      // Act
      const result = await repository.findById(scan.id);

      // Assert
      expect(result).not.toBeNull();
      expect(result?.productName).toBe('Test Product');
    });

    it('returns null when not found', async () => {
      const result = await repository.findById('non-existent-id');
      expect(result).toBeNull();
    });
  });

  describe('findByUserId', () => {
    it('returns all scans for user ordered by date', async () => {
      // Create multiple scans
      await db.insert(scans).values([
        { userId: testUserId, productName: 'Product 1', ingredients: 'a' },
        { userId: testUserId, productName: 'Product 2', ingredients: 'b' },
      ]);

      const result = await repository.findByUserId(testUserId);

      expect(result).toHaveLength(2);
      // Most recent first
      expect(result[0].createdAt.getTime()).toBeGreaterThanOrEqual(result[1].createdAt.getTime());
    });

    it('returns empty array when user has no scans', async () => {
      const result = await repository.findByUserId('other-user-id');
      expect(result).toEqual([]);
    });
  });
});

Testing Transactions

typescript
// transaction.spec.ts
describe('Transaction handling', () => {
  it('rolls back on error', async () => {
    const db = getTestDatabase();

    try {
      await db.transaction(async (tx) => {
        // Insert user
        await tx.insert(users).values({
          email: 'transaction@test.com',
          name: 'Transaction User',
        });

        // This should fail and rollback
        throw new Error('Simulated error');
      });
    } catch {
      // Expected error
    }

    // Verify rollback
    const [user] = await db.select().from(users).where(eq(users.email, 'transaction@test.com'));

    expect(user).toBeUndefined();
  });

  it('commits on success', async () => {
    const db = getTestDatabase();

    await db.transaction(async (tx) => {
      await tx.insert(users).values({
        email: 'success@test.com',
        name: 'Success User',
      });
    });

    const [user] = await db.select().from(users).where(eq(users.email, 'success@test.com'));

    expect(user).toBeDefined();
  });
});

Seeding Test Data

Test Data Factory

typescript
// test/factories/user.factory.ts
import { getTestDatabase } from '@/api/test/setup/database';
import { users, NewUser } from '@/api/database/schema';

export async function createTestUser(overrides?: Partial<NewUser>) {
  const db = getTestDatabase();

  const [user] = await db
    .insert(users)
    .values({
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      ...overrides,
    })
    .returning();

  return user;
}

export async function createTestUsers(count: number) {
  const users = [];
  for (let i = 0; i < count; i++) {
    const user = await createTestUser({
      email: `test-${i}-${Date.now()}@example.com`,
      name: `Test User ${i}`,
    });
    users.push(user);
  }
  return users;
}

Using Factories in Tests

typescript
// user.service.spec.ts
import { createTestUser, createTestUsers } from '@/api/test/factories/user.factory';

describe('UserService', () => {
  it('finds user by email', async () => {
    const user = await createTestUser({ email: 'specific@test.com' });

    const result = await userService.findByEmail('specific@test.com');

    expect(result?.id).toBe(user.id);
  });

  it('handles multiple users', async () => {
    await createTestUsers(5);

    const result = await userService.findAll();

    expect(result).toHaveLength(5);
  });
});

Complex Scenario Testing

Testing with Related Data

typescript
describe('ScanService with HairProfile', () => {
  let testUser: User;
  let testProfile: HairProfile;

  beforeEach(async () => {
    const db = getTestDatabase();

    // Create user with profile
    [testUser] = await db.insert(users).values({ email: 'profile@test.com', name: 'Profile User' }).returning();

    [testProfile] = await db
      .insert(hairProfiles)
      .values({
        userId: testUser.id,
        hairType: 'curly',
        porosity: 'high',
        concerns: ['frizz', 'dryness'],
      })
      .returning();
  });

  it('analyzes product compatibility with hair profile', async () => {
    const result = await scanService.analyzeProduct(testUser.id, 'Test Shampoo', 'water, glycerin, coconut oil');

    expect(result.compatibility).toBeDefined();
    expect(result.compatibility.score).toBeGreaterThan(0);
    expect(result.recommendedFor).toContain('curly');
  });
});

Testing Edge Cases

typescript
describe('Edge cases', () => {
  it('handles concurrent inserts', async () => {
    const db = getTestDatabase();
    const emails = Array.from({ length: 10 }, (_, i) => `concurrent-${i}@test.com`);

    // Insert concurrently
    await Promise.all(emails.map((email) => db.insert(users).values({ email, name: 'Concurrent User' })));

    // All should be inserted
    const result = await db.select().from(users);
    expect(result).toHaveLength(10);
  });

  it('handles unique constraint violation', async () => {
    const db = getTestDatabase();

    await db.insert(users).values({
      email: 'duplicate@test.com',
      name: 'Original User',
    });

    await expect(
      db.insert(users).values({
        email: 'duplicate@test.com',
        name: 'Duplicate User',
      })
    ).rejects.toThrow();
  });

  it('handles large data sets', async () => {
    const db = getTestDatabase();

    // Insert 100 users
    const userValues = Array.from({ length: 100 }, (_, i) => ({
      email: `bulk-${i}@test.com`,
      name: `Bulk User ${i}`,
    }));

    await db.insert(users).values(userValues);

    const result = await db.select().from(users);
    expect(result).toHaveLength(100);
  });
});

Performance Considerations

Container Reuse

typescript
// test/setup/database.ts
let sharedContainer: StartedPostgreSqlContainer | null = null;

export async function getSharedContainer() {
  if (!sharedContainer) {
    sharedContainer = await new PostgreSqlContainer().withReuse().start();
  }
  return sharedContainer;
}

Parallel Test Isolation

typescript
// For parallel tests, use schemas instead of separate containers
beforeEach(async () => {
  const db = getTestDatabase();
  const schemaName = `test_${Date.now()}_${Math.random().toString(36).slice(2)}`;

  await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schemaName)}`);
  await db.execute(sql`SET search_path TO ${sql.identifier(schemaName)}`);

  // Run migrations in this schema
  await migrate(db, { migrationsFolder: './drizzle' });
});

Best Practices

DO:

  • Start container once per test suite (global setup)
  • Truncate tables between tests
  • Use factories for test data
  • Test real database behavior
  • Test constraints and transactions

DON'T:

  • Start new container per test (slow)
  • Rely on test execution order
  • Leave data between tests
  • Skip database cleanup
  • Test with in-memory databases (different behavior)

Timeout Configuration

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    testTimeout: 30000, // 30s for container startup
    hookTimeout: 60000, // 60s for setup/teardown
  },
});