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

Ảnh chụp đoạn mã nền tối tách I/O khỏi logic trước một hàm làm hết sau lõi thuần cộng vỏ mỏng với Go 1.23 ChargeOrder đọc hạng khách PostgreSQL rồi giờ vàng đồng hồ rồi thuế HTTP rồi tính rồi ghi PostgreSQL, TRƯỚC luật nghiệp vụ nằm lẫn giữa bốn lần I/O gồm hàm ChargeOrder nhận ctx db pool taxURL customer items, db.QueryRow select tier from shop_customers là I/O, switch tier gold pct 15 silver pct 5, h bằng time.Now Hour happy bằng h lớn hơn bằng 14 và h nhỏ hơn 16 nếu happy thì pct cộng 5 là đồng hồ thật, resp bằng http.Get taxURL amount là I/O, tax bằng sub trừ disc nhân rate cộng 50 chia 100, db.Exec insert into shop_receipts là I/O, ghi chú muốn kiểm giờ vàng phải chạy test lúc 14 tới 16h muốn kiểm làm tròn phải dựng PG cộng máy chủ thuế giả, SAU mọi thứ hàm cần biết đi vào qua Input Quote không chạm gì ngoài tham số gồm type Input struct Items Tier Now time.Time RatePct int64, func Quote in Input trả Receipt error dùng TierPct in.Tier IsHappyHour in.Now Tax sub trừ disc in.RatePct, func ChargeOrder là vỏ gom dữ liệu gọi Quote ghi gồm tier đọc CSDL rate bằng fetchRate taxURL, r err bằng Quote Input items tier time.Now rate với ghi chú đồng hồ chỉ xuất hiện ở đây, db.Exec insert shop_receipts customer r.Total, table-driven một ca kiểm bằng một dòng gồm các dòng biên 15:59 vàng với day.Add 5 giờ 59 phút kỳ vọng Receipt 20000 1000 1520 20520 true, biên 16:00 hết vàng day.Add 6 giờ kỳ vọng 20000 0 1600 21600 false, thuế làm tròn 1257 kỳ vọng 1257 0 101 1358 false, số lượng âm kỳ vọng Receipt rỗng ErrInvalid, vòng for range cases t.Run c.name gọi Quote c.in với ghi chú 24 dòng như vậy

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ù

Ảnh chụp output thật nền tối 7 ca nhân 12 dòng 84,8 phần trăm mù giờ vàng thành 24 ca nhân 2 dòng lõi 100 phần trăm với go-lab Go 1.23 cộng pg-lab PostgreSQL 16 máy chủ thuế giả bằng httptest giờ container lúc đo 06h UTC, độ phủ và ca kiểm gồm trước 1 hàm 7 ca 90 dòng test 12 dòng một ca độ phủ 84,8 phần trăm nhánh giờ vàng không kiểm được test chỉ log 6h HappyHour false, sau lõi Quote 24 ca 53 dòng 2 dòng một ca 100 phần trăm TierPct IsHappyHour Tax Quote với 6 ca giờ vàng cộng 4 ca biên 13:59 14:00 15:59 16:00, sau vỏ ChargeOrder 2 ca 50 dòng 25 dòng một ca tổng unit cộng vỏ 88,4 phần trăm với ghi chú phần chưa phủ là fetchRate lỗi JSON không đáng một ca, giá mỗi ca go test count N không gồm biên dịch gồm trước 7 ca nhân 20 là 0,353 s tức 2,5 ms một ca vì mỗi ca nối PG mồi khách dựng httptest dọn, sau lõi 24 ca nhân 20 là 0,003 s tức 6 micro giây một ca, sau lõi 24 ca nhân 1000 là 0,089 s tức 3,7 micro giây một ca 24.000 lần kiểm trong 89 ms, sau vỏ 2 ca nhân 20 là 0,108 s tức 2,7 ms một ca vẫn đắt như trước nhưng chỉ còn 2 ca, cấy lỗi h nhỏ hơn bằng 16 thay vì h nhỏ hơn 16 nghĩa là 16h00 vẫn giảm giá gồm trước ok t2 before 0.022s xanh vì lúc chạy là 6h sẽ xanh mãi trừ khi CI chạy đúng 16h, sau FAIL TestQuote biên 16:00 hết vàng đúng một dòng đúng ca biên trong 1 ms, nói thẳng 84,8 phần trăm của bản trước là con số đẹp che một nhánh không bao giờ được kiểm có chủ đích cái mua được không chỉ là 400 lần nhanh hơn mà là nhánh phụ thuộc đồng hồ và dịch vụ ngoài trở thành kiểm được

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ề

  1. Độ 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, db bên trong luật là nhánh mù theo thiết kế.
  2. Tách lõi thuần khỏi vỏ I/O biến nhánh mù thành ca kiểm: Now và thuế suất đi vào qua Input, bốn ca biên 13:59/14:00/15:59/16:00 viết được, lỗi h <= 16 đỏ đúng một dòng trong 1 ms.
  3. 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

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.