
Ngừng Mock Database Trong Test: Hãy Dùng Testcontainers
Mock database trong unit test thường tạo ra cảm giác an toàn giả tạo. Cùng tìm hiểu cách dùng Testcontainers để chạy DB thật (Docker) cho integration test.
Nếu bạn đã làm backend một thời gian, chắc chắn bạn từng trải qua cảm giác này. Bạn hì hục viết một cái unit test cực kỳ hoàn chỉnh cho tầng truy xuất dữ liệu (Repository). Bạn cẩn thận dùng Jest để mock lại Prisma, hoặc dùng Sinon để làm giả cái pg client. Bạn cấu hình cho nó trả về đúng cái cục JSON mà logic nghiệp vụ đang cần. Test báo pass xanh rờn. Hệ thống Continuous Integration (CI) chạy ngon lành. Bạn tự tin bấm nút deploy lên production.
Và rồi... BÙM! Ứng dụng crash sấp mặt với một cái lỗi từ trên trời rơi xuống như relation "users" does not exist hoặc syntax error at or near "WHERE".
Tại sao lại như vậy? Đơn giản là vì cái mock của bạn đâu có biết rằng hôm qua ai đó đã đổi tên cột trong database thật. Cái mock của bạn cũng chả thèm quan tâm chuyện bạn đang xài một hàm chỉ có ở MySQL nhưng lại đem chạy trên server PostgreSQL. Mock database giống như việc tập bơi trên giường vậy. Nó mang lại cảm giác cực kỳ an toàn, bạn có thể quạt tay đập chân đúng nhịp, nhưng nó hoàn toàn vô dụng khi bạn thực sự nhảy xuống hồ nước sâu.
Đã đến lúc chúng ta ngừng trò chơi "giả vờ" này lại. Giải pháp thực tế nhất để test database một cách tự tin chính là Testcontainers.
Sự Ảo Tưởng Về An Toàn Của Database Mock
Khi chúng ta mock database, về bản chất chúng ta đang test sai mục tiêu. Chúng xuất sắc kiểm tra xem logic gọi database có được gọi hay không, nhưng lại chả thèm kiểm tra xem lệnh gọi đó có thực sự làm thay đổi database hay không.
Trong nhiều năm qua, anh em lập trình viên đã nghĩ ra đủ trò để lách cái vụ mock này, nhưng cách nào cũng có những nỗi đau riêng:
1. Xài Tạm In-Memory SQLite
Rất nhiều team chọn cách dựng một cái database SQLite chạy trên RAM (in-memory) để chạy test, trong khi production thì chạy PostgreSQL sừng sỏ. Nghe có vẻ hợp lý vì nó siêu nhanh. Nhưng đời không như mơ, "giọng văn" (dialect) của các loại SQL khác nhau một trời một vực. Nếu app của bạn tận dụng các tính năng xịn xò của Postgres như cột JSONB, UUID hay window functions, thì con SQLite sẽ lăn ra chết ngay lập tức. Kết quả là, để cho test có thể pass, bạn phải hạ cấp câu query của mình xuống mức cơ bản nhất, vứt bỏ hết sức mạnh thực sự của cái database production.
2. Dùng Chung Một DB Staging Một cách khác là nuôi chung một con database staging chỉ để phục vụ việc test. Cách này là một cơn ác mộng nếu team bạn có hệ thống CI chạy song song (parallel). Nhiều Pull Request chạy test cùng một lúc sẽ thi nhau ghi đè dữ liệu, gây ra tình trạng test lúc pass lúc fail (flaky tests) cực kỳ ức chế. Thời gian bạn đi fix mấy cái lỗi do đụng độ data có khi còn nhiều hơn thời gian code tính năng mới.
3. Khổ Dâm Với Docker-Compose Bằng Tay
Bạn có thể bắt mọi dev trong team phải gõ lệnh docker-compose up trước khi gõ lệnh chạy test. Cách này chạy local thì ổn, nhưng khi đem lên CI thì setup lại cồng kềnh, dễ vỡ, và test runner không thể tự chủ động quản lý vòng đời của database.
Sự Xuất Hiện Của Testcontainers

Testcontainers là một thư viện mã nguồn mở (có sẵn cho Node.js, Java, Go, Python...) cho phép bạn tạo ra các instance "dùng một lần" (throwaway) của các database phổ biến, hoặc bất kỳ thứ gì có thể chạy trong Docker.
Thay vì phải mock database hay loay hoay với các script bên ngoài, Testcontainers cho phép chính đoạn code test của bạn tự động tải image, khởi động một cái container Docker mới tinh ngay trước khi test chạy, và dọn dẹp sạch sẽ sau khi test xong. Mọi thứ được điều khiển bằng code 100%.
Ví Dụ Thực Chiến Với Node.js

Hãy cùng xem việc setup nó dễ dàng như thế nào trong một project Node.js viết bằng TypeScript với database PostgreSQL. Chúng ta sẽ xài package @testcontainers/postgresql.
Đầu tiên, cài đặt các thư viện cần thiết:
npm install -D @testcontainers/postgresql pg @types/pg
Bây giờ, thay vì viết Unit Test, chúng ta sẽ viết một cái Integration Test thực thụ cho class UserRepository. Để ý nhé, chúng ta không hề mock một cái gì cả. Code sẽ cắm thẳng vào một con Postgres thật đang chạy trong Docker.
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
describe("UserRepository Integration", () => {
let container: StartedPostgreSqlContainer;
let client: Client;
// 1. Khởi động container trước khi chạy test
beforeAll(async () => {
// Kéo image về (nếu chưa có) và dựng lên một bé Postgres thật
container = await new PostgreSqlContainer("postgres:15-alpine").start();
// 2. Kết nối client vào cái container "dùng một lần" này
client = new Client({ connectionString: container.getConnectionUri() });
await client.connect();
// 3. Chạy migration tạo bảng
await client.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
}, 30000); // Tăng timeout lỡ mạng chậm lúc pull image
// 4. Dọn dẹp chiến trường sau khi test xong
afterAll(async () => {
await client.end();
await container.stop();
});
// 5. Test thật, tự tin 100%
it("nên insert được user và query ra đúng email", async () => {
const email = "[email protected]";
// Chạy SQL thật
await client.query("INSERT INTO users (email) VALUES ($1)", [email]);
const res = await client.query("SELECT * FROM users WHERE email = $1", [email]);
expect(res.rows).toHaveLength(1);
expect(res.rows[0].email).toBe(email);
});
});
Tối Ưu Hóa Tốc Độ Xử Lý
Điểm yếu lớn nhất thường bị mang ra chê bai của Testcontainers là tốc độ. Khởi động một cái container Docker mất vài giây. Nếu project của bạn có 50 file test riêng biệt và file nào cũng đòi dựng một con container mới, cái CI của bạn sẽ chạy chậm như rùa bò.
Để vượt qua vấn đề này và giữ cho quá trình feedback nhanh gọn, bạn nên áp dụng các chiến thuật sau:
1. Dùng Chung Container Cho Cả Test Suite
Nếu bạn dùng Jest hoặc Vitest, hãy dùng các file setup global (như globalSetup và globalTeardown) để chỉ khởi động duy nhất một container database cho toàn bộ quá trình test, thay vì chạy beforeAll ở từng file lẻ tẻ. Cách này tốn thêm chút công setup nhưng bù lại tốc độ cực kỳ ấn tượng.
2. Cách Ly Dữ Liệu Bằng Transaction
Khi dùng chung một database lớn, test này có thể xả rác làm ảnh hưởng đến test kia. Thay vì xóa đi dựng lại nguyên cái DB, chiêu trò thực dụng nhất là bọc mỗi cái test vào một Transaction của SQL (BEGIN), và rồi sau khi test chạy xong, gọi lệnh ROLLBACK trong hook afterEach. Cách này giúp reset dữ liệu về trạng thái trống trơn cực nhanh chỉ trong vài mili-giây, đảm bảo các test hoàn toàn độc lập với nhau.
Hoặc "nông dân" hơn một chút, bạn có thể gọi lệnh TRUNCATE TABLE trước mỗi test case để quét sạch data.
Lời Kết
Việc lạm dụng mock cho database chỉ tạo ra một ảo tưởng về sự an toàn. Nó xúi giục bạn viết ra những dòng test luôn luôn xanh mượt ở máy local nhưng lại nổ tung khi vừa chạm mặt database thật trên production.
Với sự giúp sức của Testcontainers, bạn hoàn toàn thu hẹp được khoảng cách giữa môi trường test và môi trường thực tế. Bạn có 100% sự tự tin rằng câu lệnh SQL của mình viết đúng ngữ pháp, các constraint trong DB hoạt động trơn tru, và tầng truy xuất dữ liệu không có điểm mù nào. Đúng là việc chạy integration test với Docker sẽ tốn thêm của bạn vài chục giây so với việc chạy mock thuần túy, nhưng sự đánh đổi này là hoàn toàn xứng đáng. Bỏ thêm chút thời gian lúc chạy test để ngủ ngon vào ban đêm vẫn tốt hơn vạn lần việc bị sếp gọi dựng đầu dậy lúc 2h sáng để ngồi fix bug production.
Hãy ngừng trò tập bơi trên giường lại. Nhảy xuống nước và code như những người đàn ông thực thụ với Testcontainers nhé!
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…