Unit, integration và e2e test: chọn ranh giới theo lỗi cần bắt Cơ bản
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”API GET /quote?subtotal=100 trả số tiền sau giảm giá. Hàm tính giá có unit test: đầu vào 100 ra 90.
Sau khi refactor route, production trả 200 với body { "total": 90 } trong khi client chờ amount. Unit test vẫn xanh vì chưa từng đi qua HTTP route. Một e2e test qua UI có thể phát hiện, nhưng chậm và khó khoanh vùng nguyên nhân.
Không loại test nào “bảo đảm cả hệ thống”. Hãy chọn ranh giới test theo lỗi muốn bắt. Tên gọi giữa các đội khác nhau (Fowler cũng nhận cộng đồng chưa thống nhất thuật ngữ), nên điều quan trọng là ghi rõ test đi qua những thành phần nào và thay thế những thành phần nào.
Lỗi nào, test tầng nào
Phần tiêu đề “Lỗi nào, test tầng nào”Đây là bảng nên dán lên bàn khi review test:
| Lỗi muốn bắt | Tầng rẻ nhất bắt được | Vì sao |
|---|---|---|
| Sai ngưỡng giảm giá, off-by-one, làm tròn | Unit | Logic thuần, nhiều biến thể, chạy nhanh |
Route đổi tên field (amount thành total), sai status code |
Integration (HTTP) | Cần đi qua routing, parser, serializer |
| Thiếu unique constraint, SQL sai, migration lỗi, sai isolation | Integration với DB thật | Mock không chạy SQL hay constraint |
| Hai service lệch schema API/message | Contract test | Bắt lệch mà không cần dựng cả hai |
| Route không đăng ký ở deployment, env sai, frontend gọi sai field | E2E / smoke | Chỉ lộ khi mọi thành phần nối thật |
| Race giữa job nền và request | Integration với tín hiệu chờ đúng | Unit với mock thường che mất race |
Nguyên tắc của Fowler: đẩy test xuống tầng thấp nhất có thể; test tầng cao chỉ kiểm tra những gì tầng thấp không thể. Test tầng rộng chạy chậm hơn, và e2e “nổi tiếng là flaky” (xem flaky test).
Ba tầng, định nghĩa ngắn
Phần tiêu đề “Ba tầng, định nghĩa ngắn”- Unit: một đơn vị logic nhỏ (function, object), không biết HTTP hay DB. Phản hồi nhanh, vị trí lỗi hẹp.
- Integration: kiểm tra chỗ nối giữa các phần. Trong demo là HTTP server thật trên cổng
0gọi bằngfetch: routing, parser, hàm tính giá và JSON. Phạm vi này không gồm database hay payment gateway. - E2E: một hành trình đầy đủ trong phạm vi đã chọn (client qua hệ thống đang chạy đến kết quả cuối). Ghi rõ bên ngoài như payment provider là sandbox, stub hay gọi thật.
Pyramid hay trophy hay honeycomb?
Phần tiêu đề “Pyramid hay trophy hay honeycomb?”Pyramid là hình dung về chi phí, không phải hạn mức 70/20/10. Hai mô hình thay thế đáng biết:
| Mô hình | Trọng tâm | Phù hợp khi |
|---|---|---|
| Pyramid (Cohn, Fowler) | Nhiều unit, ít integration, rất ít e2e | Logic nghiệp vụ dày, ít phụ thuộc hạ tầng |
| Trophy (Kent C. Dodds) | “Write tests. Not too many. Mostly integration” (câu của Guillermo Rauch mà Dodds dựa vào) cùng static analysis ở đáy | Frontend/app mà test càng giống cách dùng thật càng đáng tin |
| Honeycomb (Spotify) | Phần lớn là integration test cho từng service; vài test chi tiết implement; ít nhất có thể test integrated (qua service khác) | Microservices: giá trị nằm ở điểm tương tác giữa service |
Chọn theo nơi lỗi thực sự xảy ra. Service CRUD với logic nằm trong SQL cần nhiều integration với DB thật hơn một thư viện tính toán thuần.
Đang tải diagram…
Database thật và Testcontainers
Phần tiêu đề “Database thật và Testcontainers”Test double (fake, stub, mock) giúp cô lập nhưng phải dùng có chủ ý. Mock repository trả đúng dữ liệu ta mong đợi không kiểm tra unique constraint, SQL, migration hay transaction isolation. Với rủi ro ở persistence, test với PostgreSQL thật có giá trị hơn nhiều test mock.
Testcontainers cung cấp các instance database dùng một lần, chạy trong container, nên mỗi lần chạy test có DB sạch và giống production:
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const pg = await new PostgreSqlContainer('postgres:17').start();// chạy migration thật vào pg.getConnectionUri(), chạy test, rồi:await pg.stop();Cách làm thực dụng: khởi động container một lần cho cả suite, chạy migration thật, mỗi test dùng schema hoặc transaction riêng để cô lập. Chi phí là thời gian khởi động và cần container runtime ở CI.
Contract test
Phần tiêu đề “Contract test”Khi hai service trao đổi qua API hoặc message, e2e để bắt lệch schema là đắt và flaky. Consumer-driven contract test để bên tiêu thụ mô tả dữ liệu nó cần, và bên cung cấp chạy các test đó trong pipeline của mình.
Lệch schema bị bắt ở CI của provider, không cần dựng cả hai service cùng lúc. Fowler mô tả cách tiếp cận này trong bài Practical Test Pyramid.
Khi nào dùng / không dùng
Phần tiêu đề “Khi nào dùng / không dùng”| Loại | Dùng khi | Tránh khi |
|---|---|---|
| Unit | Quy tắc nghiệp vụ nhiều nhánh | Chỉ để tăng coverage, assert chi tiết implement dễ đổi |
| Integration + DB thật | Rủi ro ở SQL, constraint, migration, transaction | Test logic thuần: dùng unit cho nhanh |
| Contract | Nhiều team/service trao đổi schema | Hai module cùng repo, cùng release |
| E2E | Vài hành trình sống còn (đăng nhập, thanh toán) | Lặp lại mọi tổ hợp giảm giá |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo (Node test runner, không cần database):
npx --prefix examples ts-node examples/src/testing-levels/quote.test.tsKỳ vọng tổng kết pass 2, fail 0. Hai phép thử sửa code cho thấy mỗi tầng phát tín hiệu khác nhau:
| Sửa code | Unit | Integration |
|---|---|---|
{ amount } thành { total: amount } |
pass | fail |
subtotal >= 100 thành > 100 |
fail | fail |
Demo cũng kiểm tra thiếu subtotal: không chặn thì Number(null) thành 0 và input sai trông như hợp lệ. Số tiền thật cần kiểu và chính sách làm tròn riêng; bài này chỉ dùng nó để nhìn ranh giới test.
Trade-offs
Phần tiêu đề “Trade-offs”Phân tầng kiểm thử
- Unit cho phản hồi nhanh và vị trí lỗi hẹp.
- Integration bắt lỗi wiring, schema, query và contract ở ranh giới thực.
- E2E xác nhận vài hành trình quan trọng trong cấu hình gần thực tế.
Điều phải quản lý
- Test tầng rộng cần môi trường, dữ liệu, cleanup; chạy nhiều dễ chậm và flaky.
- Mock quá nhiều làm test pass theo giả định của mock.
- Lặp cùng một case ở mọi tầng tăng chi phí bảo trì mà không thêm tín hiệu.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist production
Phần tiêu đề “Checklist production”- Mỗi test ghi rõ nó đi qua thành phần nào và thay thế thành phần nào.
- Quy tắc nghiệp vụ nhiều nhánh nằm ở unit; không lặp lại ở e2e.
- Mọi route công khai có ít nhất một integration test cho status, body và lỗi input.
- Rủi ro persistence (constraint, migration, transaction) được test với DB thật, ví dụ qua Testcontainers.
- Ranh giới giữa service có contract test thay vì dựa vào e2e.
- E2E giới hạn ở vài hành trình sống còn, có smoke test sau deploy.
- Dữ liệu cô lập giữa test; không phụ thuộc thứ tự chạy; port động.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao unit test quote(100) không phát hiện response đổi amount thành total? (2) Lỗi unique constraint ở PostgreSQL nên bắt ở tầng nào? (3) Khi nào honeycomb hợp lý hơn pyramid? (4) Contract test thay thế e2e ở điểm nào?