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

Ảnh chụp đoạn mã nền tối kim tự tháp kiểm thử cùng một dịch vụ ba tầng test 8 lỗi cấy vào với Go 1.23 gói shop gồm price.go thuần repo.go pgx PostgreSQL 16 handler.go HTTP go test theo build tag, ba tầng tách bằng build tag gồm unit price_test.go kiểm Discount Tax Price Validate không I/O 4 test, integration repo_test.go kiểm SaveOrder TotalsByCustomer PostgreSQL thật 2 test mỗi test một tx rồi ROLLBACK, e2e e2e_test.go POST orders qua handler price repo với httptest cộng PG 4 test dọn bằng DELETE, 8 lỗi mỗi lần cấy đúng một bằng biến môi trường SHOP_BUG gồm 1 Tax cắt phần lẻ thay vì làm tròn amount nhân 8 chia 100 thay vì amount nhân 8 cộng 50 chia 100 là logic, 2 Discount ngưỡng lệch một sub lớn hơn 100000 thay vì lớn hơn bằng là logic, 3 SaveOrder lưu đồng thay vì xu total chia 100 là ánh xạ dữ liệu, 4 SaveOrder quên customer_id INSERT thiếu cột NOT NULL là SQL, 5 Handler bỏ qua lỗi Validate đơn qty 0 vẫn được tạo là HTTP, 6 Handler đổi tên trường JSON total thay vì total_cents là hợp đồng, 7 Handler trả 200 thay vì 201 là HTTP, 8 TotalsByCustomer thiếu WHERE trả đơn của mọi khách là SQL, cách đo gồm vòng lặp for bug 0 tới 8 for layer unit integration e2e chạy SHOP_BUG go test count 1 tags layer run test của tầng để lấy PASS FAIL và số dòng báo lỗi, go test count 100 để lấy chi phí một test khấu hao 100 lượt, go test cover để lấy độ phủ của riêng từng tầng

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, tag integration): 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, tag e2e): bốn test gửi HTTP thật qua httptest và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

Ảnh chụp output thật nền tối unit bắt 2 trên 8 integration 4 trên 8 e2e 8 trên 8 giá 1 180 180 với go-lab Go 1.23 cộng pg-lab PostgreSQL 16 cùng máy SHOP_BUG 0 là không cấy lỗi, bảng tầng nào đỏ khi cấy lỗi nào gồm lỗi 1 Tax làm tròn sai unit bắt integration lọt e2e bắt với ghi chú integration lọt vì dữ liệu của nó chia hết không chạm làm tròn, lỗi 2 Discount ngưỡng lệch một cả ba bắt, lỗi 3 lưu đồng thay vì xu integration và e2e bắt, lỗi 4 quên customer_id NOT NULL integration và e2e bắt, lỗi 5 handler bỏ qua Validate chỉ e2e bắt với ghi chú Validate thuần vẫn đúng lỗi ở chỗ gọi nó, lỗi 6 đổi tên trường JSON chỉ e2e, lỗi 7 trả 200 thay vì 201 chỉ e2e, lỗi 8 thiếu WHERE integration và e2e, bắt được 2 trên 8 4 trên 8 8 trên 8, độ phủ câu lệnh 33,8 phần trăm 59,2 phần trăm 78,9 phần trăm, giá mỗi test go test count 100 không gồm biên dịch gồm unit 4 test nhân 100 bằng 5 ms là 12,5 micro giây một test, integration 2 test nhân 100 bằng 460 ms là 2,3 ms một test kết nối PG cộng tx cộng rollback gấp 184 lần unit, e2e 4 test nhân 100 bằng 901 ms là 2,25 ms một test httptest cộng pool cộng DELETE bằng integration, biên dịch cộng chạy 1 lần wall unit 113 ms integration 147 ms e2e 162 ms ở quy mô này biên dịch át hết, cùng lỗi 1 mỗi tầng nói gì gồm unit price_test.go dòng 23 Tax 1257 bằng 100 muốn 101 là hàm nào đầu vào nào sai bao nhiêu, e2e e2e_test.go dòng 68 total_cents bằng 1357 muốn 1358 chỉ biết tổng sai không biết price handler hay CSDL, lỗi 4 integration 1 dòng null value in column customer_id SQLSTATE 23502, e2e 5 dòng status 500 total_cents nil CSDL lưu 0 mọi test đỏ không dòng nào nói vì sao, nói thẳng e2e ở đây là httptest trong cùng tiến trình với CSDL cùng máy nên e2e bằng integration về giá là cận dưới e2e thật tiến trình riêng mạng trình duyệt dữ liệu mồi đắt hơn nhiều bước nhảy 184 lần nằm ở chỗ chạm CSDL không ở HTTP

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ề

  1. 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.
  2. 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á đó.
  3. 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

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.