Hướng dẫn sử dụng Testcontainers với TypeScript: Môi trường Integration Test chuẩn Docker cho PostgreSQL và Redis

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Ác mộng muôn thuở: Khi test ở local xanh lè nhưng lên CI đỏ rực

Cảnh tượng này hẳn quen thuộc với dân backend: chạy từng test file đơn lẻ ở máy cá nhân thì pass 100%. Đến khi gộp lại chạy cả suite hoặc đẩy lên CI/CD, hàng chục test case lăn ra đỏ rực.

Team mình từng thấm thía điều này khi refactor một dịch vụ thanh toán khoảng 50.000 dòng code. Muốn tự tin chạm vào core, chúng mình phải phủ kín integration test cho luồng giao dịch giữa PostgreSQL và Redis. Kết quả? Tuần đầu tiên, tỷ lệ flaky test lên tới 35%.

Nguyên nhân rất vụn vặt: một test case chạy trước insert dở dang bản ghi mà quên rollback. Test case phía sau chạy tới liền ăn ngay lỗi vi phạm UNIQUE constraint. Cả team mất đứt hai ngày chỉ để debug xem test nào ‘bỏ rác’ lại database.

Thêm vào đó, mỗi người trong team lại chạy một phiên bản database khác nhau. Người cài Postgres 14 qua Homebrew, người dựng Postgres 16 trên Docker cố định. Kết quả là hành vi câu lệnh JSONB và transaction chạy ở từng máy cho ra kết quả lệch nhau thấy rõ.

Ba thủ phạm khiến test suite của bạn kém ổn định

Khi bóc tách hệ thống test gặp sự cố, hầu hết lỗi đều rơi vào ba nguyên nhân sau:

  • Shared State (Dữ liệu dùng chung bị bẩn): Cả test suite trỏ chung vào một instance database. Chỉ cần sót một lệnh TRUNCATE TABLE hoặc FLUSHALL, các test chạy sau sẽ nhận dữ liệu sai lệch ngay lập tức.
  • Xung đột cổng (Port Collision): Để test chạy nhanh, các test runner hiện đại như Vitest hay Jest mặc định chạy song song nhiều worker thread. Khi 4 worker cùng đâm vào port 5432 hoặc 6379, race condition chắc chắn xảy ra.
  • Bẫy lạm dụng Mock (Over-mocking): Nhiều team mock sạch Prisma, TypeORM hay ioredis để test chạy trong 50ms. Đánh đổi lại, test chẳng còn giá trị bảo vệ. Mock không thể cảnh báo deadlock thật, không bắt được lỗi sai schema, cũng không kiểm tra được logic TTL của Redis.

Hai giải pháp tạm thời và giới hạn thực tế

Trước khi tìm ra công cụ chuẩn, các đội ngũ kỹ thuật thường chọn hai lối đi tắt:

1. Dùng thư viện In-Memory (SQLite, redis-mock)

Cách này thay Postgres bằng SQLite in-memory và thế Redis bằng redis-mock.

  • Ưu điểm: Chạy rất nhanh, tốn ít RAM và không cần cài Docker.
  • Nhược điểm: SQL dialect của SQLite khác xa PostgreSQL. Bạn không thể test cú pháp ON CONFLICT DO UPDATE, kiểu UUID, JSONB phức tạp hay câu lệnh advisory lock. Tương tự, redis-mock cũng ‘bó tay’ trước Redis Streams hay Lua script tùy biến.

2. Dựng sẵn container bằng Docker Compose

Tạo file docker-compose.test.yml rồi khởi động container trước khi chạy test runner.

  • Ưu điểm: Chạy trên database và cache engine thật 100%.
  • Nhược điểm: Phải quản lý vòng đời container thủ công bằng shell script. Developer hay quên gõ docker compose up trước khi chạy test. Quan trọng nhất, cách này vẫn dùng chung một port cố định nên không thể chạy test file song song an toàn.

Giải pháp triệt để: Quản lý hạ tầng bằng code với Testcontainers

Thay vì dựa vào script bên ngoài, bạn có thể biến chính mã test thành nơi quản lý vòng đời container. Testcontainers cho phép bạn khởi tạo, cấu hình và hủy Docker container thật trực tiếp từ code TypeScript.

Mỗi khi test suite khởi động, thư viện sẽ kéo image chỉ định về máy. Nó tự động map container sang một dynamic port ngẫu nhiên (ví dụ 49152 thay vì 5432), loại bỏ hoàn toàn nguy cơ đụng cổng.

Đặc biệt, Testcontainers đi kèm một sidecar container tên là Ryuk. Container này giám sát tiến trình test. Khi Node.js tắt—kể cả do crash bất ngờ—Ryuk sẽ tự động dọn sạch mọi container liên quan, không để lại tiến trình rác chiếm RAM máy bạn.

Cài đặt thư viện

Cài đặt các gói phụ thuộc cần thiết cho dự án TypeScript dùng Vitest:

npm install -D vitest typescript @types/node
npm install -D testcontainers @testcontainers/postgresql @testcontainers/redis
npm install pg ioredis
npm install -D @types/pg

Dựng môi trường test độc lập

Tạo một helper class để đóng gói logic khởi động và giải phóng tài nguyên:

// test/test-env.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis';
import { Pool } from 'pg';
import Redis from 'ioredis';

export class TestEnvironment {
  private pgContainer?: StartedPostgreSqlContainer;
  private redisContainer?: StartedRedisContainer;
  public dbPool?: Pool;
  public redisClient?: Redis;

  async start() {
    // Khởi tạo container Postgres với đúng image thực tế của dự án
    this.pgContainer = await new PostgreSqlContainer('postgres:16-alpine')
      .withDatabase('test_db')
      .withUsername('test_user')
      .withPassword('test_pass')
      .start();

    // Khởi tạo container Redis
    this.redisContainer = await new RedisContainer('redis:7-alpine').start();

    // Lấy connection URI chứa port động do Testcontainers cấp phát
    this.dbPool = new Pool({
      connectionString: this.pgContainer.getConnectionUri(),
    });

    this.redisClient = new Redis(this.redisContainer.getConnectionUrl());

    // Tạo bảng thử nghiệm
    await this.dbPool.query(`
      CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        name VARCHAR(100) NOT NULL
      );
    `);
  }

  async stop() {
    // Ngắt kết nối client
    await this.dbPool?.end();
    this.redisClient?.disconnect();

    // Dừng và xóa container
    await this.pgContainer?.stop();
    await this.redisContainer?.stop();
  }
}

Viết kịch bản Integration Test

Dưới đây là ca kiểm thử thực tế: lưu user vào PostgreSQL và ghi cache session token vào Redis:

// test/user-service.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { TestEnvironment } from './test-env';

describe('User & Session Integration Test', () => {
  const env = new TestEnvironment();

  // Dành 60s cho lần chạy đầu tiên vì cần thời gian pull Docker image
  beforeAll(async () => {
    await env.start();
  }, 60000);

  // Dọn dẹp sạch sẽ sau khi toàn bộ suite chạy xong
  afterAll(async () => {
    await env.stop();
  });

  // Reset dữ liệu trước mỗi test case để tránh nhiễm chéo
  beforeEach(async () => {
    await env.dbPool?.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE;');
    await env.redisClient?.flushdb();
  });

  it('lưu user vào PostgreSQL và ghi session vào Redis chuẩn xác', async () => {
    const email = '[email protected]';
    const name = 'Alex Dev';

    // 1. Thao tác với PostgreSQL thật
    const insertRes = await env.dbPool!.query(
      'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name;',
      [email, name]
    );
    const newUser = insertRes.rows[0];

    expect(newUser.id).toBeDefined();
    expect(newUser.email).toBe(email);

    // 2. Thao tác với Redis cache thật
    const sessionKey = `session:${newUser.id}`;
    const token = 'jwt-mock-secret-xyz';
    await env.redisClient!.set(sessionKey, token, 'EX', 3600);

    const cachedToken = await env.redisClient!.get(sessionKey);
    expect(cachedToken).toBe(token);
  });

  it('bắt lỗi trùng email chính xác nhờ PostgreSQL Unique Constraint', async () => {
    const email = '[email protected]';

    await env.dbPool!.query('INSERT INTO users (email, name) VALUES ($1, $2);', [email, 'User A']);

    // Cố tình ghi đè để kiểm tra constraint ở tầng database
    await expect(
      env.dbPool!.query('INSERT INTO users (email, name) VALUES ($1, $2);', [email, 'User B'])
    ).rejects.toThrow(/duplicate key value/);
  });
});

Bí quyết tối ưu hiệu năng Testcontainers

Dùng container thật cho độ tin cậy tuyệt đối, nhưng cái giá phải trả là tốc độ. Dưới đây là 4 mẹo giúp test suite của bạn vừa chuẩn vừa nhanh:

  • Bật chế độ Reusable Container ở local: Mặc định mỗi file test sẽ tạo một container mới. Bạn hãy gọi .withReuse() khi khởi tạo container, đồng thời thêm dòng testcontainers.reuse.enable=true vào file ~/.testcontainers.properties. Nhờ đó, container được giữ nguyên giữa các lần gõ lệnh test ở local, giảm thời gian chờ từ 8 giây xuống dưới 0.5 giây.
  • Tách riêng biệt Unit Test và Integration Test: Đừng chạy Testcontainers cho logic thuần như format ngày tháng hay validate email. Hãy đặt tên file riêng biệt như *.integration.test.ts và tách lệnh chạy npm run test:integration để tiết kiệm thời gian chạy CI hàng ngày.
  • Gắn Docker Socket trên CI runner: Khi chạy trên GitHub Actions hoặc GitLab CI, runner cần quyền điều khiển Docker daemon. Hãy chắc chắn bạn đã mount /var/run/docker.sock vào job environment.
  • Ưu tiên dùng image Alpine: Bản postgres:16-alpine chỉ nặng khoảng 80MB so với hơn 350MB của bản standard. Việc chọn base image nhẹ giúp giảm đáng kể thời gian kéo image trên CI runner mới.
Share: