Integration test với Testcontainers: Postgres, Redis, Kafka thật cho mỗi lần chạy mà CI không chậm như rùa Trung cấp
Thời gian đọc ước tính: 11 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một service đặt hàng viết bằng NestJS có hai phần cần test cùng hạ tầng thật: OrderRepository chạy trên PostgreSQL, và OrderService vừa cache trạng thái vào Redis vừa publish order.created lên Kafka.
Suite xanh hết trên SQLite in-memory và ioredis-mock. Lên production, client gửi currency: "USDT" và lệnh insert lỗi 22001 value too long. Test không hề thấy: SQLite bỏ qua số 3 trong char(3).
Bài này trả lời: làm sao chạy test trên engine thật, giữ các test không giẫm lên nhau, và giữ CI đủ nhanh để không ai muốn bỏ qua?
Chọn tầng test nằm ở bài Unit, integration và e2e; bài này bắt đầu sau quyết định đó.
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Code repository, constraint, cú pháp SQL, migration | Postgres thật qua Testcontainers | Có Docker ở máy dev và trong CI |
| Nhiều file test, cùng một engine | Một container mỗi lần chạy (global setup) + một database mỗi file, clone từ template | Migration chạy một lần |
| Test dùng một connection do mình nắm | BEGIN … ROLLBACK mỗi test |
Code được test không tự mở transaction, không dùng connection khác |
| Code dùng pool, tự commit hoặc chạy việc nền | TRUNCATE sau mỗi test |
Bảng nhỏ; không file nào khác dùng chung database |
| Test cần database mới, migrate đầy đủ | CREATE DATABASE … TEMPLATE |
Chấp nhận ~35 ms mỗi test |
| Logic nghiệp vụ thuần, API SaaS bên ngoài | Không dùng container | Unit test, hoặc contract test và stub |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Test double là thứ thay cho dependency thật: mock kiểm tra lời gọi, fake viết lại hành vi (SQLite đóng vai “database”, ioredis-mock đóng vai “Redis”). Fake chỉ giống engine ở những gì tác giả đã viết lại. Nó không chạy code của engine.
Cùng một lệnh INSERT chạy trên hai engine trong sqlite-vs-postgres.test.ts (SQLite 3.53 qua node:sqlite, PostgreSQL 17):
| Input | SQLite (fake) | PostgreSQL (thật) |
|---|---|---|
currency = 'USDT' vào char(3) |
Lưu thành USDT |
Lỗi 22001 |
total_cents = 1250.5 vào integer |
Lưu được, typeof = real |
Lỗi 22P02 |
'Alice@Shop.vn' LIKE 'alice@shop.vn' |
1 (khớp) |
false |
Tài liệu SQLite xác nhận: tham số độ dài như VARCHAR(255) bị bỏ qua. Bảng STRICT từ chối 1250.5 nhưng lại không chấp nhận kiểu CHAR(3).
Testcontainers là thư viện khởi động container Docker dùng một lần ngay từ code test, chờ đến khi sẵn sàng, rồi trả về host và một port ngẫu nhiên. Bản Node gồm package testcontainers và các module như @testcontainers/postgresql, @testcontainers/redis, @testcontainers/kafka; major hiện tại là 12 (demo dùng 12.2.1).
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Mỗi file test một container nghĩa là lặp lại cùng một lần khởi động ở mọi file. Demo khởi động một Postgres cho mỗi lần chạy và cấp cho mỗi file một database riêng:
label sessionglobalSetup chạy một lần. Lần start container đầu tiên cũng bật Ryuk và mở một connection tới nó.
Trạng thái sau từng bước:
- Ryuk khởi động: lần start container đầu tiên cũng bật Ryuk (reaper) để canh tài nguyên của session này.
- Postgres sẵn sàng: wait strategy mặc định của module là health check
pg_isreadyvà port đã bind, nên không cầnsleep. - Migration một lần: schema vào
app_template; runner ngắt kết nối, vì PostgreSQL từ chối copy template khi còn ai đang kết nối. - Mỗi file một database: mỗi process con chạy
CREATE DATABASE test_<pid> TEMPLATE app_template. Các file không thấy dữ liệu của nhau. - Cô lập từng test: trong database của mình, mỗi file chọn rollback hoặc
TRUNCATE. - Dọn dẹp:
globalTeardowndừng container; nếu process crash, Ryuk xoá nó.
Test runner của Node (từ v24.0, “Early development”) gọi globalSetup một lần trước khi sinh process cho các file test, nên biến môi trường đặt ở đó đến được mọi file:
// postgres-global.ts (nạp qua global-setup.js, xem bên dưới)export async function globalSetup() { container = await startMigratedPostgres(); // PostgreSqlContainer('postgres:17-alpine') process.env.TC_PG_ADMIN_URI = adminUriOf(container); // .../postgres trên port ngẫu nhiên}export async function globalTeardown() { await container?.stop();}Vitest có cách chia tương tự: globalSetup chạy một lần trước khi tạo worker, truyền giá trị cho test bằng project.provide() và inject().
Cô lập các test với nhau
Phần tiêu đề “Cô lập các test với nhau”Chi phí đo được mỗi test, 100 vòng, 20 dòng seed cộng một insert (bench.ts):
| Chiến lược | Chi phí/test | Hỏng khi |
|---|---|---|
BEGIN … ROLLBACK |
0,8–1,1 ms | Code tự commit, dùng pool, hoặc connection khác cần thấy dữ liệu |
TRUNCATE orders RESTART IDENTITY |
1,3–1,7 ms | Hai file dùng chung database (lệnh lấy lock ACCESS EXCLUSIVE) |
CREATE DATABASE … TEMPLATE + DROP |
33–39 ms | Hiếm; chỉ chậm hơn |
Đang tải diagram…
Trong NestJS, rollback mỗi test hợp với code lấy connection từ transaction context, như bài transaction với CLS. OrderService nhận một Pool, nên file test của nó dùng TRUNCATE.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Wait strategy: không có module hay health check thì Testcontainers chờ port tối đa 60 s. Có thêm
Wait.forLogMessage,forHealthCheck,forHttp,forSuccessfulCommand,forAll; module Postgres kết hợp health check với port, startup timeout 120 s. - Port: mỗi port expose được map sang port host ngẫu nhiên (
getMappedPort(5432)). File chạy song song và job CI song song không đụng nhau; port cố định thì tài liệu ghi “not recommended”. - Worker song song:
node --testchạy mỗi file trong một process con, tối đaos.availableParallelism() - 1file cùng lúc. Chín file clone cùng một template một lúc vẫn chạy tốt ở cả hai lần chạy 12 file. - Ryuk: container xoá mọi thứ mang label của session khoảng 10 s sau khi connection từ process test đóng (reconnection timeout mặc định). Kill runner bằng
SIGKILL: container Redis biến mất sau 11 s. TESTCONTAINERS_RYUK_DISABLED=truetắt Ryuk; khi đóglobalTeardownlà đường dọn dẹp duy nhất.- Reuse:
.withReuse()giữ container cùng cấu hình cho lần chạy sau. Tiết kiệm vài giây ở máy dev; nhưng cũng giữ luôn dữ liệu, nên đừng bật trong CI. - CI: runner
ubuntu-24.04do GitHub host có sẵn Docker Engine, không cần cấu hình service:
jobs: integration: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 24 } - run: npm ci --prefix examples - run: npm --prefix examples run test:testcontainersDùng ts-node thì file --test-global-setup phải là JS thuần (global-setup.js): runner nạp nó bằng import(), không đi qua hook TypeScript.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo trong examples/src/testcontainers/ có OrdersModule của NestJS; test thay ba provider PG_POOL, REDIS, KAFKA_PRODUCER bằng overrideProvider(...).
npm --prefix examples run test:testcontainers # 3 file, 9 testnpm --prefix examples run bench:testcontainers # thời gian khởi động và cô lậpOutput thật (rút gọn)
[global-setup] postgres + migrations ready in 1383 ms[order-repository] shared-container database ready in 138 ms[order-service] postgres database: 57 ms[order-service] kafka container: 258 ms[order-service] redis container: 769 ms[order-service] kafka ready (createTopics): 2227 ms[order-service] consumer group joined: 419 msℹ pass 9ℹ fail 0ℹ duration_ms 6276.618625Qua 15 lần chạy liên tiếp, suite mất 5,7–8,0 s, xanh hết. Kafka chiếm phần lớn: broker nhận request 2,2–4,6 s sau khi start() đã trả về.
Máy và giới hạn: Apple M5 (10 core), Docker 29.5.3, Node 24.17, testcontainers 12.2.1, image đã pull sẵn. Runner CI mới tinh còn phải pull image; số thay đổi mỗi lần chạy.
Trade-offs
Phần tiêu đề “Trade-offs”Engine thật trong container
- Constraint, cú pháp SQL,
LIKE, kiểu dữ liệu và migration chạy như production. - Port ngẫu nhiên và database riêng mỗi file cho phép chạy file song song.
- Ryuk dọn sau khi crash; ngoài Docker không cần cài gì thêm.
Cái giá phải trả
- Cần runtime tương thích Docker ở máy dev và trong CI.
- Vài giây khởi động (Kafka chậm nhất ở đây), cộng thời gian pull image trên runner mới.
- Suite xanh chỉ có nghĩa khi tag image khớp major version của production.
Checklist production
Phần tiêu đề “Checklist production”- Ghim tag image theo major version của production (
postgres:17-alpine), không dùnglatest. - Khởi động mỗi engine một lần mỗi lần chạy trong global setup; migrate một lần vào template database.
- Cấp cho mỗi file test một database riêng (
CREATE DATABASE … TEMPLATE) để các file chạy song song. - Chọn cách cô lập từng test theo cách code dùng connection: rollback,
TRUNCATE, hoặc clone mới. - Coi
start()là “container đã lên”, rồi gọi một lệnh client thật (query,createTopics) trước test đầu tiên. - Giữ Ryuk bật trong CI; chỉ dùng reuse ở máy dev.
- Log thời gian khởi động trong suite để image chậm hay lần nâng Kafka hiện ngay trong output CI.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Con số lạ nhất trong demo đến từ lỗi này:
Lỗi thứ hai nằm ở connection:
Rollback một transaction mà code được test không hề dùng
Hay gặp
beforeEach(() => client.query('BEGIN')); // trên client #1// OrderService ghi qua Pool: connection khác, auto-commitafterEach(() => client.query('ROLLBACK')); // dữ liệu vẫn cònNên viết
afterEach(async () => { await pool.query('TRUNCATE orders RESTART IDENTITY'); await redis.flushdb();});Vì sao
Rollback chỉ huỷ những gì chạy trên đúng connection đó. Code dùng connection pool cần cách reset có hiệu lực trên mọi connection.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Mặc định mỗi file test một container | 12 file: 20–29 s thay vì 5–6 s | Global setup + mỗi file một database |
Port host cố định (5432:5432) |
Job song song đụng nhau | Dùng getMappedPort() |
sleep(5000) sau khi start |
Chậm mà vẫn flaky | Wait strategy + một lệnh client thật |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao SQLite nhận
'USDT'vàochar(3)còn PostgreSQL trả22001? CREATE DATABASE test_a TEMPLATE app_templatelỗi ngay sau migration. Cái gì vẫn đang kết nối?- Một file test rollback mỗi test, nhưng service ghi qua
Pool. Vì sao dữ liệu rò sang test kế tiếp? - Runner bị kill bằng
SIGKILL. Cái gì xoá container, và khoảng bao lâu sau?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Testcontainers — PostgreSQL: API của module,
snapshot()/restoreSnapshot(). - Testcontainers — Wait strategies: chờ port mặc định, các strategy khác, startup timeout.
- Testcontainers — Containers: port host ngẫu nhiên,
withReuse(), cách dừng container. - Testcontainers — Configuration:
TESTCONTAINERS_RYUK_DISABLEDvà các biến môi trường khác. - moby-ryuk README: khi nào Ryuk xoá tài nguyên, các timeout.
- Node.js — Global setup,
--test-concurrency: setup một lần mỗi lần chạy, file song song. - PostgreSQL 17 — Template Databases: vì sao template không được có connection nào.
- PostgreSQL 17 — TRUNCATE:
RESTART IDENTITY, lockACCESS EXCLUSIVE. - SQLite — Datatypes: type affinity, tham số độ dài bị bỏ qua, bảng STRICT.
- Runner images — Ubuntu 24.04: Docker cài sẵn.