Kim tự tháp kiểm thử — nhiều unit, ít integration, rất ít end-to-end — là hình vẽ xuất hiện trong mọi buổi nói về test, và gần như không bao giờ đi kèm một con số. "Unit nhanh", "e2e đắt", "e2e bắt được nhiều lỗi hơn" — nhanh bao nhiêu, đắt bao nhiêu, bắt được lỗi nào? Sê-ri này đo. Bài mở đầu lấy một dịch vụ nhỏ có đủ ba lớp (logic thuần, truy cập PostgreSQL, HTTP), viết test ở cả ba tầng, rồi cấy tám lỗi vào từng cái một và xem tầng nào đỏ, mất bao lâu, và thông báo lỗi nói được gì.
Ba tầng test và tám lỗi

Hình 1: Ba tầng test tách bằng build tag để chạy riêng từng tầng; tám lỗi bật bằng biến môi trường để cấy từng cái một.
Gói shop có ba file sản phẩm. price.go là logic thuần: kiểm tra đơn, tính tạm tính, giảm giá theo ngưỡng (từ 500.000 đồng giảm 5%, từ 1.000.000 giảm 10%), thuế 8% làm tròn nửa lên. repo.go lưu đơn và liệt kê tổng tiền theo khách bằng pgx trên PostgreSQL thật. handler.go nhận JSON, gọi kiểm tra, tính giá, lưu, trả 201 kèm total_cents.
Ba tầng test:
- Unit (
price_test.go): bốn test bảng cho các hàm thuần — ngưỡng giảm giá đúng tại 50.000 và 100.000 xu, làm tròn thuế tại 1.256 và 1.257, một đơn mẫu, và hàm kiểm tra. Không I/O. - Integration (
repo_test.go, tagintegration): hai test chạy trên PostgreSQL thật, mỗi test mở một transaction và rollback khi xong nên không để lại dữ liệu. - End-to-end (
e2e_test.go, tage2e): bốn test gửi HTTP thật quahttptestvào handler nối với PostgreSQL thật, kiểm mã trạng thái, JSON trả về, và dữ liệu đã lưu.
Tám lỗi chia đều ba lớp: hai lỗi logic (làm tròn, ngưỡng lệch một), ba lỗi ở tầng dữ liệu (lưu sai đơn vị, quên cột NOT NULL, thiếu WHERE), ba lỗi ở tầng HTTP (bỏ qua kiểm tra, đổi tên trường JSON, sai mã trạng thái). Mỗi lượt đo bật đúng một lỗi, chạy đủ ba tầng, ghi PASS/FAIL và số dòng báo lỗi. Thêm hai phép đo nữa: -count=100 để lấy chi phí một test sau khi khấu hao biên dịch, và -cover để lấy độ phủ câu lệnh của riêng từng tầng.
Đo thật: ai bắt được gì, với giá nào

Hình 2: Ma trận lỗi và tầng, giá mỗi test, và thông báo lỗi của cùng một lỗi ở hai tầng.
| tầng | lỗi bắt được | độ phủ | giá một test | thông báo lỗi |
|---|---|---|---|---|
| unit | 2/8 | 33,8% | 12,5 µs | hàm, đầu vào, giá trị sai |
| integration | 4/8 | 59,2% | 2,3 ms (×184) | câu SQL, mã lỗi SQLSTATE |
| end-to-end | 8/8 | 78,9% | 2,25 ms (×180) | mã trạng thái, tổng sai |
End-to-end bắt cả 8, và đó là lý do người ta thích nó. Bốn test đi qua đủ ba lớp nên không lỗi nào thoát: lỗi logic lộ ra ở tổng tiền, lỗi dữ liệu lộ ra ở 500 hoặc ở số đã lưu, lỗi HTTP lộ ra ở mã trạng thái và tên trường. Unit chỉ bắt được 2/8 — đúng hai lỗi nằm trong hàm thuần. Ba lỗi HTTP (5, 6, 7) chỉ end-to-end bắt được, và lỗi 5 đáng chú ý: hàm Validate vẫn hoàn toàn đúng và unit test của nó vẫn xanh; lỗi nằm ở chỗ handler không dùng kết quả của nó. Unit test kiểm hàm, không kiểm việc hàm có được gọi.
Integration lọt lỗi làm tròn — vì dữ liệu của nó tình cờ "đẹp". Test tích hợp dùng đơn 100.000 xu, giảm 10.000, thuế 8% của 90.000 là 7.200 chẵn, nên cắt phần lẻ hay làm tròn đều ra cùng số. Unit test có ca 1.257 xu vì nó được viết để kiểm làm tròn; integration được viết để kiểm lưu và đọc. Tầng cao hơn không tự động bao hàm tầng thấp hơn: nó chỉ kiểm những đường đi mà dữ liệu của nó chạm tới.
Giá: bước nhảy 184 lần nằm ở chỗ chạm cơ sở dữ liệu, không ở HTTP. Khấu hao qua 100 lượt, một unit test tốn 12,5 µs; một integration test tốn 2,3 ms vì phải kết nối, tạo transaction, rollback; một end-to-end test tốn 2,25 ms — bằng integration, vì httptest chạy trong cùng tiến trình và HTTP qua loopback gần như miễn phí. Ở quy mô bốn test, biên dịch (khoảng 110 ms) át hết mọi thứ; ở quy mô một nghìn test, 12,5 µs với 2,3 ms là 12 mili-giây so với 2,3 giây mỗi lần chạy. End-to-end thật — tiến trình riêng, dữ liệu mồi, trình duyệt — còn đắt hơn nhiều; con số ở đây là cận dưới.
Thông báo lỗi: tầng càng cao càng xa nguyên nhân. Cùng lỗi làm tròn, unit nói Tax(1257) = 100, muốn 101 — hàm nào, đầu vào nào, sai bao nhiêu; end-to-end nói total_cents = 1357, muốn 1358 — tổng sai một xu, ở đâu đó trong ba lớp. Lỗi quên cột NOT NULL: integration in một dòng có tên cột và mã 23502; end-to-end in năm dòng đỏ — mọi test đều hỏng với status 500, total_cents = nil, CSDL lưu 0 — và không dòng nào nói vì sao. Một lỗi hạ tầng làm cả bộ end-to-end đỏ đồng loạt; độ phủ cao đổi bằng khả năng khoanh vùng thấp.
Đánh đổi cần cân nhắc
Kim tự tháp là lời khuyên về tỉ lệ, dựa trên hai trục đo được ở đây: giá mỗi test và khả năng khoanh vùng. Unit rẻ 184 lần và chỉ thẳng vào hàm, nên nó là chỗ để có nhiều ca — mọi ngưỡng, mọi biên, mọi cách làm tròn. Integration đắt hơn nhưng là tầng duy nhất thấy SQL thật, ràng buộc thật, ánh xạ kiểu thật (lỗi 3, 4, 8 chỉ lộ ở đây trở lên). End-to-end là lưới cuối: ít ca, mỗi ca một luồng chính, để bắt những lỗi "ghép nối" như lỗi 5.
Phần đáy không bắt được lỗi ghép nối, và không bắt được là do thiết kế, không do thiếu test. Ba lỗi HTTP cần một tầng kiểm handler; tầng đó không nhất thiết phải là end-to-end với cơ sở dữ liệu thật — một test handler với repo giả cũng bắt được 5, 6, 7 với giá gần unit. Đó là chủ đề của phần 2 và 3: tách I/O để phần logic kiểm được rẻ, và chọn test double sao cho test handler không giòn.
Độ phủ nói ít hơn bạn nghĩ. End-to-end phủ 78,9% với bốn test; unit phủ 33,8% với bốn test. Nhưng độ phủ của integration là 59,2% mà vẫn lọt lỗi làm tròn nằm trong phần đã phủ — câu lệnh được chạy qua không có nghĩa là kết quả của nó được kiểm. Phần 7 (mutation testing) đo thẳng vào khoảng cách này.
Phạm vi đo. Một gói nhỏ, bốn test mỗi tầng, cơ sở dữ liệu cùng máy. Tỉ lệ bắt lỗi phụ thuộc vào việc tám lỗi được chọn thế nào — ở đây chia đều ba lớp một cách có chủ ý. Giá mỗi test là số đo thật trên máy này; tỉ lệ giữa các tầng ổn định hơn con số tuyệt đối.
Ba ý mang về
- Mỗi tầng bắt đúng lớp lỗi nó chạm tới: unit 2/8 (logic), integration 4/8 (thêm SQL và ánh xạ), end-to-end 8/8 (thêm HTTP và ghép nối). Hàm đúng không có nghĩa là hàm được gọi — lỗi 5 chỉ lộ khi đi qua handler.
- Giá nhảy 184 lần ở chỗ chạm cơ sở dữ liệu (12,5 µs so với 2,3 ms một test), còn HTTP trong tiến trình gần như miễn phí; end-to-end thật đắt hơn nữa. Số ca kiểm nên tỉ lệ nghịch với giá đó.
- Tầng càng cao, lỗi càng xa nguyên nhân: unit chỉ đúng hàm và đầu vào, end-to-end chỉ nói tổng sai, và một lỗi hạ tầng làm cả bộ end-to-end đỏ không lý do. Giữ phần lớn ca kiểm ở tầng khoanh vùng được, giữ end-to-end cho luồng chính.
Nguồn
- Mike Cohn — Succeeding with Agile (2009), chương về kim tự tháp kiểm thử
- Martin Fowler — The Practical Test Pyramid (Ham Vocke, 2018): https://martinfowler.com/articles/practical-test-pyramid.html
- Kent C. Dodds — The Testing Trophy and Testing Classifications (2021): https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications
- Go — Testing flags: -count, -cover, -run, build constraints: https://pkg.go.dev/cmd/go#hdr-Testing_flags
- Google — Software Engineering at Google (2020), chương 11–14 về kiểm thử
Phần sau tách I/O khỏi logic trong chính gói này để phần kiểm được rẻ to ra: table-driven test, hàm thuần, và đo độ phủ với tốc độ trước và sau khi tách.