Á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 TABLEhoặcFLUSHALL, 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
5432hoặc6379, 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ểuUUID,JSONBphứ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 uptrướ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òngtestcontainers.reuse.enable=truevà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.tsvà tách lệnh chạynpm 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.sockvào job environment. - Ưu tiên dùng image Alpine: Bản
postgres:16-alpinechỉ 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.

