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

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

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.

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

  • 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 0 gọi bằng fetch: 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 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…

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.

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.

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á

Chạy demo (Node test runner, không cần database):

Terminal window
npx --prefix examples ts-node examples/src/testing-levels/quote.test.ts

Kỳ 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.

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.
  1. Mỗi test ghi rõ nó đi qua thành phần nào và thay thế thành phần nào.
  2. Quy tắc nghiệp vụ nhiều nhánh nằm ở unit; không lặp lại ở e2e.
  3. Mọi route công khai có ít nhất một integration test cho status, body và lỗi input.
  4. Rủi ro persistence (constraint, migration, transaction) được test với DB thật, ví dụ qua Testcontainers.
  5. Ranh giới giữa service có contract test thay vì dựa vào e2e.
  6. E2E giới hạn ở vài hành trình sống còn, có smoke test sau deploy.
  7. Dữ liệu cô lập giữa test; không phụ thuộc thứ tự chạy; port động.

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?