Bỏ qua để đến nội dung
Knowledge Hub

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

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 đó.

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

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).

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:

Một container mỗi lần chạy: migrate một lần vào template, clone mỗi file một database, Ryuk dọn nếu runner chết
Một container mỗi lần chạy: migrate một lần vào template, clone mỗi file một database, Ryuk dọn nếu runner chếtBước 1/6: globalSetup 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ó.node --testglobalSetupRyukcontainer dọn rácđang canhorder-repositoryprocess con Aorder-serviceprocess con BPostgres containerport host ngẫu nhiênlabel session
node --test → Ryuklabel session
Bước 1/6

globalSetup 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:

  1. 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.
  2. Postgres sẵn sàng: wait strategy mặc định của module là health check pg_isready và port đã bind, nên không cần sleep.
  3. 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.
  4. 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.
  5. Cô lập từng test: trong database của mình, mỗi file chọn rollback hoặc TRUNCATE.
  6. Dọn dẹp: globalTeardown dừ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().

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.

  • 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 --test chạy mỗi file trong một process con, tối đa os.availableParallelism() - 1 file 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=true tắt Ryuk; khi đó globalTeardown là đườ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.04 do 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:testcontainers

Dù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.

Demo trong examples/src/testcontainers/ có OrdersModule của NestJS; test thay ba provider PG_POOL, REDIS, KAFKA_PRODUCER bằng overrideProvider(...).

Terminal window
npm --prefix examples run test:testcontainers # 3 file, 9 test
npm --prefix examples run bench:testcontainers # thời gian khởi động và cô lập
Output 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.618625

Qua 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.

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.
  1. Ghim tag image theo major version của production (postgres:17-alpine), không dùng latest.
  2. 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.
  3. Cấp cho mỗi file test một database riêng (CREATE DATABASE … TEMPLATE) để các file chạy song song.
  4. Chọn cách cô lập từng test theo cách code dùng connection: rollback, TRUNCATE, hoặc clone mới.
  5. 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.
  6. Giữ Ryuk bật trong CI; chỉ dùng reuse ở máy dev.
  7. Log thời gian khởi động trong suite để image chậm hay lần nâng Kafka hiện ngay trong output CI.

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-commit
afterEach(() => client.query('ROLLBACK')); // dữ liệu vẫn còn

Nê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
  1. Vì sao SQLite nhận 'USDT' vào char(3) còn PostgreSQL trả 22001?
  2. CREATE DATABASE test_a TEMPLATE app_template lỗi ngay sau migration. Cái gì vẫn đang kết nối?
  3. 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?
  4. Runner bị kill bằng SIGKILL. Cái gì xoá container, và khoảng bao lâu sau?