Phần 1 đo được unit test rẻ hơn 184 lần test chạm cơ sở dữ liệu, và khoanh vùng lỗi tốt hơn hẳn. Câu hỏi hiển nhiên tiếp theo: vậy sao không viết phần lớn test ở tầng đó? Vì mã thật không cho phép — hàm nghiệp vụ điển hình đọc cơ sở dữ liệu, xem đồng hồ, gọi dịch vụ ngoài, rồi mới tính. Muốn kiểm một phép làm tròn phải dựng cả ba thứ kia. Bài này lấy đúng một hàm như vậy, viết test cho nó ở dạng nguyên bản, rồi tách thành lõi thuần và vỏ I/O, và đo cái gì đổi: độ phủ, số dòng test mỗi ca, giá mỗi ca, và nhánh nào từ chỗ không kiểm được trở thành kiểm được.
Một hàm làm hết, và cùng hàm đó sau khi tách

Hình 1: Trước, time.Now() và http.Get nằm giữa luật tính giá. Sau, đồng hồ và thuế suất đi vào qua tham số; mỗi ca kiểm là một dòng trong bảng.
Hàm ChargeOrder tính tiền một đơn: đọc hạng khách từ PostgreSQL (gold giảm 15%, silver 5%), cộng thêm 5% nếu đang trong giờ vàng 14–16h theo đồng hồ hệ thống, hỏi thuế suất từ một dịch vụ HTTP, tính thuế làm tròn nửa lên, rồi ghi biên lai. Bốn lần I/O, một đoạn luật ở giữa.
Bản trước giữ nguyên hình dạng đó. Test của nó buộc phải dựng đủ: nối PostgreSQL, mồi một khách với hạng mong muốn, dựng máy chủ thuế giả bằng httptest, gọi hàm, dọn. Bảy ca kiểm — ba hạng khách, đơn không hợp lệ, dịch vụ thuế hỏng, khách không tồn tại — và một ca thứ bảy chỉ ghi log xem hiện tại có phải giờ vàng không, vì không có cách nào bảo hàm "bây giờ là 15 giờ". Ba ca hạng khách còn phải chấp nhận hai đáp án tuỳ lúc chạy.
Bản sau tách làm hai. Quote(Input) là lõi thuần: Input mang sẵn mọi thứ luật cần — danh sách hàng, hạng khách, thời điểm, thuế suất — và hàm không chạm gì ngoài tham số. ChargeOrder thành vỏ: đọc hạng, hỏi thuế, gọi Quote với time.Now(), ghi biên lai; không còn một dòng luật nào. Test lõi viết dạng bảng: 24 dòng, mỗi dòng một tên, một Input, một kết quả mong đợi. Test vỏ còn hai ca: đường đi qua được, và một phụ thuộc hỏng.
Phép đo cuối: cấy một lỗi biên — h <= 16 thay vì h < 16, tức 16 giờ 00 vẫn được giảm — rồi chạy lại cả hai bộ test.
Đo thật: độ phủ, giá, và nhánh mù

Hình 2: Cùng một luật nghiệp vụ, trước và sau khi tách. Dòng cuối là phép thử quyết định: lỗi biên 16 giờ.
| trước: một hàm | sau: lõi Quote |
sau: vỏ ChargeOrder |
|
|---|---|---|---|
| ca kiểm | 7 | 24 | 2 |
| dòng test mỗi ca | 12 | 2 | 25 |
| giá mỗi ca | 2,5 ms | 6 µs | 2,7 ms |
| độ phủ | 84,8% | 100% lõi | 88,4% tổng |
| nhánh giờ vàng | không kiểm được | 6 ca + 4 ca biên | — |
lỗi h <= 16 |
xanh | đỏ đúng ca biên | — |
84,8% là con số đẹp che một nhánh mù. Bản trước phủ gần hết câu lệnh, kể cả hai dòng của giờ vàng — vì time.Now().Hour() được chạy, chỉ là không ai kiểm được nó rẽ đúng hay sai. Test duy nhất về giờ vàng ghi log "6h → HappyHour=false" và kết thúc; ba ca hạng khách phải viết if r.HappyHour { want = ... } để chấp nhận cả hai đáp án tuỳ giờ CI chạy. Độ phủ đếm câu lệnh đã chạy, không đếm câu lệnh đã được khẳng định. Phần 7 (mutation testing) sẽ đo thẳng vào khoảng cách đó; ở đây nó lộ ra bằng một phép thử nhỏ: cấy lỗi h <= 16, bản trước vẫn xanh — và sẽ xanh mãi trừ khi CI tình cờ chạy đúng 16 giờ, lúc đó nó đỏ mà không ai hiểu vì sao.
Tách xong, mọi nhánh đều có tên. Lõi Quote nhận Now qua tham số, nên bảng có sáu ca giờ vàng theo hạng và bốn ca biên: 13:59 chưa vàng, 14:00 vàng, 15:59 vàng, 16:00 hết vàng — thứ không thể viết khi đồng hồ là của hệ điều hành. Thêm múi giờ khác, làm tròn lên và xuống, thuế 0% và 10%, giá 0, số lượng âm, đơn lớn tràn số nhỏ. Cấy lỗi h <= 16: đỏ đúng một ca, biên_16:00_hết_vàng, trong một mili-giây. Bốn hàm của lõi đạt 100% câu lệnh — và lần này 100% có nghĩa, vì mỗi câu lệnh đều có ít nhất một ca khẳng định kết quả của nó.
Giá: 2,5 ms xuống 6 µs mỗi ca, hơn 400 lần. Bảy ca của bản trước tốn 0,353 giây cho 20 lượt vì mỗi ca nối PostgreSQL, mồi dữ liệu, dựng máy chủ giả và dọn. 24 ca của lõi tốn 3 mili-giây cho 20 lượt; chạy 1.000 lượt — 24.000 phép kiểm — mất 89 mili-giây. Vỏ vẫn đắt như cũ, 2,7 ms một ca, nhưng chỉ còn hai ca vì nó không còn luật nào để kiểm: đường đi qua được và một phụ thuộc hỏng là đủ.
Hai dòng một ca thay vì mười hai. 90 dòng test cho 7 ca ở bản trước, phần lớn là dựng và dọn lặp lại; 53 dòng cho 24 ca ở lõi, mỗi ca đúng một dòng trong bảng cộng phần khung dùng chung. Thêm ca thứ 25 là thêm một dòng — nên người ta thêm thật, và bảng lớn dần theo từng lỗi production thay vì nằm yên ở bảy ca vì viết thêm một ca tốn mười hai dòng.
Đánh đổi cần cân nhắc
Đây là "functional core, imperative shell", và điều kiện của nó là lõi không được gọi ngược ra ngoài. Mọi thứ lõi cần — dữ liệu, thời điểm, cấu hình, số ngẫu nhiên — phải đi vào qua tham số. Khi một quyết định ở giữa luật cần thêm dữ liệu (ví dụ: chỉ hỏi lịch sử mua nếu đơn trên một triệu), bạn hoặc tải trước mọi thứ ở vỏ (tốn I/O thừa), hoặc chia lõi thành hai bước với vỏ ở giữa (thêm một kiểu dữ liệu trung gian). Cả hai đều chấp nhận được; nhét db vào lõi "cho tiện" là mất toàn bộ cái vừa mua.
Vỏ vẫn cần test, nhưng ít. 88,4% tổng sau khi cộng hai ca tích hợp; phần chưa phủ là nhánh JSON hỏng trong fetchRate — không đáng một ca riêng. Vỏ là chỗ của phần 1: lỗi ánh xạ, lỗi SQL, lỗi gọi nhầm hàm — vài ca tích hợp là đủ, vì luật đã rời khỏi đó.
Bảng lớn có cái giá của bảng lớn. 24 dòng đọc được; 240 dòng thì không, và một ca sai khó tìm. Chia bảng theo chủ đề (giờ vàng, làm tròn, hợp lệ), đặt tên ca đúng ý định, và để t.Run in tên ca khi đỏ — như biên_16:00_hết_vàng ở trên — là khác biệt giữa bảng và đống số.
Phạm vi đo. Một hàm, một luật đơn giản, cơ sở dữ liệu cùng máy. Tỉ lệ 400 lần phụ thuộc vào vỏ nặng bao nhiêu I/O; con số tuyệt đối sẽ khác. Nhánh mù phụ thuộc đồng hồ là ví dụ dễ thấy nhất; cùng cơ chế áp cho số ngẫu nhiên, biến môi trường, và bất kỳ phụ thuộc nào hàm tự đi lấy thay vì được đưa cho.
Ba ý mang về
- Độ phủ cao không có nghĩa là nhánh được kiểm: 84,8% của bản trước che một nhánh giờ vàng mà không ca nào khẳng định được; cấy lỗi biên 16h, bộ test vẫn xanh. Nhánh phụ thuộc
time.Now(),http.Get,dbbên trong luật là nhánh mù theo thiết kế. - Tách lõi thuần khỏi vỏ I/O biến nhánh mù thành ca kiểm:
Nowvà thuế suất đi vào quaInput, bốn ca biên 13:59/14:00/15:59/16:00 viết được, lỗih <= 16đỏ đúng một dòng trong 1 ms. - Giá đổi từ 2,5 ms xuống 6 µs mỗi ca và từ 12 xuống 2 dòng mỗi ca, nên bảng lớn dần theo từng lỗi thật; vỏ vẫn đắt nhưng chỉ còn hai ca vì không còn luật để kiểm.
Nguồn
- Gary Bernhardt — Boundaries (2012), nguồn gốc của "functional core, imperative shell": https://www.destroyallsoftware.com/talks/boundaries
- Dave Cheney — Prefer table driven tests (2019): https://dave.cheney.net/2019/05/07/prefer-table-driven-tests
- Go Wiki — Table Driven Tests: https://go.dev/wiki/TableDrivenTests
- Michael Feathers — Working Effectively with Legacy Code (2004), chương về seam
- Go — Coverage profiling: https://go.dev/blog/cover
Phần sau ở lại với cái vỏ: khi vẫn phải kiểm mã có I/O, chọn stub, mock hay fake — và đo mock làm test giòn thế nào bằng cách refactor giữ nguyên hành vi rồi đếm số test phải sửa.