Nếu đã từng gỡ một lỗi “chạy được trên máy tôi” nhưng chết cứng ở môi trường staging, bạn sẽ thấm thía vì sao integration test là một tầng không thể bỏ qua. Đó là nơi hệ thống chạm vào thế giới thực: cơ sở dữ liệu, message broker, cache, dịch vụ bên thứ ba, network, quyền và phiên bản phụ thuộc. Khi các mảnh ghép ấy ăn khớp, bạn yên tâm deploy; khi chúng lệch nhau một chút, mọi lý thuyết đẹp đẽ của unit test đều vô nghĩa.
Tin tốt là integration test không phải là một mớ hỗn độn khó kiểm soát. Với một bộ nguyên tắc rõ ràng, bạn có thể biến chúng thành tuyến phòng thủ đanh thép nhưng vẫn gọn nhẹ, nhanh và đáng tin cậy. Bài viết này tổng hợp 5 bí quyết tinh gọn, thực chiến, kèm ví dụ có thể áp dụng ngay cho nhóm của bạn.
Integration test không nhằm kiểm tra mọi dòng code; nó xác nhận rằng hành trình quan trọng của người dùng đi qua các ranh giới tích hợp đều hoạt động đúng. Tư duy theo hành trình và ranh giới giúp bạn viết ít nhưng trúng.
[API Gateway] -> [Order Service] -> [Postgres]
-> [Message Broker]
-> [Payment Service]
Gợi ý cách viết một case theo cấu trúc Given – When – Then:
Ví dụ minh hoạ bằng Node.js với Jest và Supertest. Ở đây, chúng ta test ở ranh giới HTTP, quan sát database và message broker qua adapter giả lập hoặc container thật:
// tests/order.int.spec.js
const request = require('supertest')
const app = require('../src/app')
const db = require('../src/infra/db')
const brokerSpy = require('../src/infra/broker/spy')
describe('Create order - integration', () => {
beforeAll(async () => {
await db.migrate()
await db.seed()
})
afterAll(async () => {
await db.close()
})
test('returns 201 and publishes OrderCreated when payment succeeds', async () => {
// Given
await db.insertProduct({ id: 'p1', price: 1000, stock: 10 })
await db.insertProduct({ id: 'p2', price: 2000, stock: 5 })
// When
const res = await request(app)
.post('/orders')
.send({ items: [ { id: 'p1', qty: 1 }, { id: 'p2', qty: 2 } ], paymentMethod: 'card' })
// Then
expect(res.status).toBe(201)
expect(res.body.status).toBe('paid')
const order = await db.findOrderById(res.body.id)
expect(order.total).toBe(1000 + 2 * 2000)
const events = brokerSpy.getPublished('OrderCreated')
expect(events.some(e => e.orderId === res.body.id)).toBe(true)
})
})
Điểm mấu chốt:
Mẹo nhỏ: viết check-list cho mỗi luồng nghiệp vụ, gồm đầu vào tối thiểu, đầu ra mong đợi, side effects và lỗi thường gặp. Check-list này là kim chỉ nam cho test case, đồng thời là tài liệu sống cho nhóm.
Hơn phân nửa sự mệt mỏi với integration test đến từ dữ liệu lộn xộn: seed to đùng, khó đọc, phụ thuộc lẫn nhau và khó tái sử dụng. Hãy thiết kế chiến lược dữ liệu như thiết kế mã nguồn.
Nguyên tắc vàng:
Một số mẫu áp dụng nhanh:
// tests/factories.js
const db = require('../src/infra/db')
const makeId = prefix => `${prefix}_${Math.random().toString(36).slice(2, 8)}`
async function productFactory(overrides = {}) {
const product = {
id: makeId('p'),
name: overrides.name || 'Demo product',
price: overrides.price ?? 1000,
stock: overrides.stock ?? 10
}
await db.insertProduct(product)
return product
}
async function userFactory(overrides = {}) {
const user = {
id: makeId('u'),
email: overrides.email || `${makeId('user')}@test.local`,
role: overrides.role || 'customer'
}
await db.insertUser(user)
return user
}
module.exports = { productFactory, userFactory }
beforeAll(() => jest.useFakeTimers())
beforeEach(() => {
jest.setSystemTime(new Date('2025-01-01T00:00:00Z'))
})
Ngoài ra, ghi chú mô hình dữ liệu quan trọng như ràng buộc khoá ngoại, unique constraint, trigger. Integration test nên chạm vào các ràng buộc đó để tránh bất ngờ khi triển khai thực tế.
Giá trị của integration test nằm ở việc mô phỏng đủ chân thực các phụ thuộc: database, cache, queue, dịch vụ bên ngoài. Đừng mock tất cả; hãy chạy những thứ bạn kiểm soát trong container riêng, và giả lập hợp lý những thứ ngoài sự kiểm soát (ví dụ sandbox của nhà cung cấp thanh toán).
Ba lớp môi trường nên làm quen:
Ví dụ Docker Compose tối giản cho Postgres và Redis:
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: app_test
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
redis:
image: redis:7-alpine
ports:
- 6379:6379
Trong CI, tránh chia sẻ state bằng cách đặt tên database hoặc schema theo biến môi trường của job, hoặc dùng Testcontainers. Với Node.js, thư viện testcontainers giúp bạn khởi tạo phụ thuộc ngay trong test:
// tests/setup-containers.js
const { GenericContainer } = require('testcontainers')
let postgresContainer
module.exports = async () => {
postgresContainer = await new GenericContainer('postgres', '16-alpine')
.withEnv('POSTGRES_DB', 'app_test')
.withEnv('POSTGRES_USER', 'app')
.withEnv('POSTGRES_PASSWORD', 'secret')
.withExposedPorts(5432)
.start()
process.env.DB_HOST = postgresContainer.getHost()
process.env.DB_PORT = String(postgresContainer.getMappedPort(5432))
process.env.DB_NAME = 'app_test'
process.env.DB_USER = 'app'
process.env.DB_PASS = 'secret'
}
Hướng dẫn nhanh để môi trường vừa giống production vừa gọn nhẹ:
Đừng quên định nghĩa healthcheck cho container, và trong test hãy chờ đến khi phụ thuộc sẵn sàng thay vì dùng sleep cứng. Một tiện ích chờ có điều kiện sẽ giúp test bớt flaky:
async function waitUntil(check, opts = { timeoutMs: 5000, intervalMs: 100 }) {
const start = Date.now()
while (true) {
if (await check()) return
if (Date.now() - start > opts.timeoutMs) throw new Error('Timeout waiting for condition')
await new Promise(r => setTimeout(r, opts.intervalMs))
}
}
Integration test chậm không phải vì bản chất, mà vì chúng ta vô tình trộn state và side effect không kiểm soát. Quy tắc là: mỗi test có thế giới riêng; nếu phải chia sẻ, thì reset về trạng thái sạch nhanh chóng.
Các chiến thuật thực dụng:
Ví dụ: chạy Jest song song với schema riêng cho mỗi worker.
// tests/jest.setup.db.js
const db = require('../src/infra/db')
beforeAll(async () => {
const workerId = process.env.JEST_WORKER_ID || '1'
const schema = `test_${workerId}`
process.env.DB_SCHEMA = schema
await db.createSchema(schema)
await db.migrate(schema)
})
afterAll(async () => {
await db.dropSchema(process.env.DB_SCHEMA)
await db.close()
})
Trường hợp bạn không kiểm soát hoàn toàn connection: chọn mô hình database per suite hoặc per test bằng container ephemeral. Trade-off là tốn tài nguyên hơn nhưng gần như loại bỏ rò rỉ state.
Tổ chức lại test để giảm số lần khởi động app:
Giảm flaky ở tầng quan sát:
Cuối cùng, nếu có test flaky, đừng vội thêm retry vô hạn. Hãy đo đạc và cách ly: đánh dấu flaky để chạy riêng, ghi lại log, metrics và trace, sau đó sửa tận gốc. Retry chỉ nên là phao cứu sinh tạm thời trong CI để không chặn deploy khi bạn đã có vé kỹ thuật để khắc phục.
Integration test chỉ thực sự có giá trị khi chúng được chạy tự động, có số liệu để tối ưu, và được coi như một phần của thiết kế hệ thống. Hãy biến chúng thành tài sản sống thay vì gánh nặng.
Đo lường những gì quan trọng:
Tích hợp CI nghiêm túc:
Báo cáo và quan sát:
Quy ước và cấu trúc để dễ sống chung:
tests/
order/
create.int.spec.js
refund.int.spec.js
auth/
login.int.spec.js
refresh-token.int.spec.js
factories.js
jest.setup.db.js
Tự động hoá vòng đời test:
Đảm bảo test tiến hoá cùng hệ thống:
Một số checklist nhanh bạn có thể dán cạnh bàn làm việc:
Khi bạn xem integration test là một phần của thiết kế, mọi quyết định kiến trúc cũng thân thiện với test hơn: ranh giới rõ ràng, hợp đồng ổn định, side effect kiểm soát được, dữ liệu có vòng đời minh bạch. Đó là vòng tròn tích cực khiến hệ thống của bạn dễ thay đổi nhưng hiếm khi vỡ vụn khi triển khai.
Để kết lại, hãy bắt đầu nhỏ nhưng đúng hướng: chọn 1–2 hành trình cốt lõi, dựng dữ liệu có chủ đích, chạy phụ thuộc thật bằng container, đảm bảo test song song không giẫm chân nhau và đo lường kết quả trên CI. Khi nền tảng vững, bạn sẽ tự tin mở rộng suite mà không lo chậm hay flaky. Integration test, nếu được thiết kế như một sản phẩm, sẽ trả lại cho bạn những lần deploy nhẹ nhàng, những cuộc điều tra lỗi ngắn gọn, và một đội ngũ tin tưởng vào chất lượng của chính công việc mình làm.