Phần 2 đẩy luật nghiệp vụ vào lõi thuần để test không cần thay thế gì. Nhưng vỏ vẫn còn đó, và trong mã thật không phải lúc nào cũng tách được sạch: một dịch vụ gọi repo, gọi mailer, gọi cổng thanh toán, và bạn muốn kiểm nó mà không có cơ sở dữ liệu lẫn máy chủ thư. Lúc đó phải thay phụ thuộc bằng một thứ giả — và "thứ giả" có ba hình dạng khác nhau với ba hệ quả khác nhau, mà người ta hay gọi chung là "mock". Bài này viết cùng một bộ test theo ba kiểu, rồi đo hai thứ: khi refactor giữ nguyên hành vi, kiểu nào vỡ; khi có lỗi thật, kiểu nào bắt được.

Một dịch vụ, ba kiểu thay thế

Ảnh chụp đoạn mã nền tối test double cùng một Service ba cách thay thế Repo và Mailer với Go 1.23 Service.Place gồm GetCustomer rồi giảm giá theo hạng rồi SaveOrder rồi SaveEvent rồi GetCustomer lấy email rồi Send, STUB trả giá trị đóng sẵn không nhớ gì test chỉ nhìn giá trị trả về gồm type stubRepo với cust custErr saveErr, GetCustomer trả r.cust r.custErr, SaveOrder trả 42 r.saveErr, o err bằng s.Place 1 10000 nếu err khác nil hoặc o.Total khác 8500 thì t.Fatal với ghi chú 43 dòng 4 test, MOCK ghi lại từng lời gọi và đối số test khẳng định đúng chuỗi tương tác gồm mockRepo GetCustomer append calls GetCustomer id, want là danh sách GetCustomer 1 SaveOrder SaveEvent placed 42 GetCustomer 1, reflect.DeepEqual r.calls want nếu không bằng thì t.Errorf chuỗi gọi muốn, reflect.DeepEqual r.saved với Order 1 8500 hạng gold với ghi chú 87 dòng 4 test, FAKE cài đặt thật thu nhỏ trong bộ nhớ test khẳng định trạng thái sau khi chạy gồm fakeRepo với customers map orders map events nextID, SaveOrder lưu vào f.orders tăng nextID trả id, SaveEvent kiểm đơn tồn tại rồi append events, kiểm len r.orders bằng 1 r.orders 1 Total bằng 8500 m.sent a@x bằng 1 với ghi chú 94 dòng 4 test, cách đo 5 refactor giữ nguyên hành vi cộng 4 lỗi thật chạy cả ba bộ test trên từng biến thể gồm R1 gộp 2 lần GetCustomer thành 1 R2 gửi thư trước khi ghi sự kiện R3 đổi định dạng ghi chú nội bộ R4 thử lại SaveOrder một lần R5 thêm Ping vào interface Repo B1 quên gửi thư B2 lưu đơn hai lần B3 giảm 80 phần trăm thay vì 85 B4 gửi thư cả khi lưu hỏng

Hình 1: Stub trả giá trị, mock ghi lời gọi, fake giữ trạng thái. Bốn ca kiểm giống nhau ở cả ba kiểu; khác nhau là test khẳng định cái gì.

Service.Place làm năm việc: lấy khách để biết hạng, giảm 15% nếu gold, lưu đơn, ghi sự kiện, lấy khách lần nữa để có email (một lần gọi thừa cố ý để lại từ mã cũ — vẫn đúng), rồi gửi thư. Hai phụ thuộc đều là interface: Repo ba phương thức, Mailer một.

Ba bộ test, mỗi bộ bốn ca giống nhau (gold được giảm, silver không, khách không tồn tại, lưu đơn hỏng), khác nhau ở thứ giả:

  • Stub: struct trả giá trị đóng sẵn, không ghi nhớ gì. Test chỉ nhìn Order và error trả về. 43 dòng.
  • Mock: ghi lại tên và đối số của từng lời gọi; test khẳng định đúng chuỗi — GetCustomer(1), SaveOrder, SaveEvent(placed,42), GetCustomer(1) — và đối số truyền vào. 87 dòng.
  • Fake: cài đặt thu nhỏ trong bộ nhớ — map khách, map đơn, danh sách sự kiện, bộ đếm thư theo địa chỉ — với vài luật thật (ghi sự kiện cho đơn không tồn tại là lỗi). Test khẳng định trạng thái sau khi chạy: có đúng một đơn với tổng 8.500, đúng một thư tới địa chỉ đó. 94 dòng.

Phép đo: từ service.go gốc sinh ra 9 bản. Năm refactor giữ nguyên hành vi bên ngoài — gộp hai lần GetCustomer thành một, đổi chỗ gửi thư và ghi sự kiện, đổi định dạng ghi chú nội bộ, thử lại SaveOrder một lần khi lỗi, thêm Ping() vào interface. Bốn lỗi thật — quên gửi thư, lưu đơn hai lần, giảm 80% thay vì 85%, gửi thư cả khi lưu hỏng. Chạy cả ba bộ test trên từng bản, đếm test đỏ.

Đo thật: ai vỡ khi refactor, ai bắt khi có lỗi

Ảnh chụp output thật nền tối mock vỡ 4 trên 4 refactor stub lọt 3 trên 4 lỗi fake 0 và 4 trên 4 với go test count 1 tags stub mock fake trên 10 bản của service.go mỗi bộ 4 test, số test đỏ trên 4 theo biến thể gồm gốc không đổi 0 0 0, R1 gộp 2 lần GetCustomer thành 1 stub 0 mock 2 fake 0, R2 gửi thư trước khi ghi sự kiện 0 1 0, R3 đổi định dạng ghi chú nội bộ 0 2 0, R4 thử lại SaveOrder một lần khi lỗi 0 1 0, R5 thêm Ping vào interface Repo BUILD FAIL cả ba với lỗi does not implement Repo missing method Ping, B1 quên gửi thư stub 0 lọt mock 2 fake 2, B2 lưu đơn hai lần tính tiền đôi stub 0 lọt mock 3 fake 2, B3 giảm giá 80 phần trăm thay vì 85 1 1 1, B4 gửi thư cả khi lưu hỏng stub 0 lọt mock 1 fake 1, cộng lại gồm stub refactor làm vỡ 0 trên 4 lỗi thật bắt được 1 trên 4 43 dòng, mock vỡ 4 trên 4 với 6 test phải sửa bắt 4 trên 4 87 dòng, fake vỡ 0 trên 4 bắt 4 trên 4 94 dòng, R5 thêm một phương thức vào interface làm vỡ cả ba bộ ngay lúc biên dịch là giá của double viết tay, vì sao mock vỡ ở R1 nó khẳng định cách làm không khẳng định kết quả gồm want với GetCustomer 1 SaveOrder SaveEvent placed 42 GetCustomer 1 trong đó lần gọi thừa được đóng đinh, R1 bỏ lần gọi thừa hành vi bên ngoài y nguyên mock đỏ phải sửa test để cho phép mã tốt hơn, nói thẳng stub rẻ nhất và không bao giờ vỡ vì nó không nhìn thấy tác dụng phụ nên cũng không bắt được lỗi về tác dụng phụ mock và fake cùng bắt 4 trên 4 khác nhau ở chỗ mock đóng đinh cách gọi fake chỉ giữ hệ quả của lời gọi

Hình 2: Ma trận biến thể và kiểu test double. Hàng R là refactor không đổi hành vi (test đỏ là báo oan); hàng B là lỗi thật (test đỏ là bắt được).

kiểu refactor làm vỡ (R1–R4) lỗi thật bắt được (B1–B4) dòng test
stub 0/4 1/4 43
mock 4/4, 6 test phải sửa 4/4 87
fake 0/4 4/4 94

Stub không bao giờ vỡ, vì nó không nhìn thấy gì. Cả bốn refactor đều xanh với stub. Nhưng ba trong bốn lỗi thật cũng xanh: quên gửi thư, lưu đơn hai lần (tính tiền đôi), gửi thư khi lưu hỏng — ba lỗi về tác dụng phụ, và test kiểu stub chỉ khẳng định giá trị trả về. Lỗi duy nhất nó bắt là giảm giá sai, vì cái đó nằm trong Order trả về. Stub hợp với hàm mà toàn bộ ý nghĩa nằm ở kết quả; với dịch vụ mà việc chính là gây ra chuyện gì đó ở hệ thống khác, stub cho cảm giác an toàn giả.

Mock bắt đủ, và vỡ đủ. Bốn lỗi, bốn lần đỏ. Nhưng bốn refactor cũng đỏ, tổng cộng 6 test phải sửa. Nhìn vào R1 là thấy vì sao: test đóng đinh chuỗi GetCustomer(1), SaveOrder, SaveEvent, GetCustomer(1) — kể cả lần gọi thừa. Bỏ lần gọi thừa là làm mã tốt hơn với hành vi bên ngoài y nguyên, và test đỏ, buộc người sửa phải sửa test để "cho phép" cải tiến. R3 đổi định dạng một chuỗi ghi chú nội bộ cũng làm hai test đỏ vì mock so sánh nguyên Order truyền vào SaveOrder. Mock khẳng định cách làm, nên mọi thay đổi cách làm đều là lỗi trong mắt nó — kể cả thay đổi đúng.

Fake bắt đủ và không vỡ. Fake giữ hệ quả của lời gọi — đơn nào đã lưu, thư nào đã gửi — chứ không giữ lời gọi. Gộp hai lần GetCustomer thành một: trạng thái cuối y nguyên, xanh. Đổi thứ tự gửi thư và ghi sự kiện: trạng thái cuối y nguyên, xanh. Lưu đơn hai lần: map đơn có hai phần tử, đỏ. Gửi thư khi lưu hỏng: bộ đếm thư khác 0 trong khi map đơn rỗng, đỏ. Nó đắt hơn chút khi viết (94 dòng so với 87) vì phải cài vài luật thật, nhưng viết một lần dùng cho mọi test của gói — mock thì mỗi test dựng kỳ vọng riêng.

R5 làm vỡ cả ba, ngay lúc biên dịch. Thêm Ping() vào interface Repo là ba bộ test cùng báo does not implement Repo (missing method Ping). Đây không phải lỗi của kiểu nào mà là giá của double viết tay cho interface: interface lớn lên là mọi bản giả lớn theo. Cách chữa thông thường: nhúng interface vào struct giả (struct{ Repo }) để phương thức chưa cài gọi tới là panic thay vì không biên dịch được, hoặc sinh mã giả tự động; hoặc giữ interface nhỏ theo đúng chỗ dùng — Service không cần Ping() thì đừng bắt nó nhận một Repo có Ping().

Đánh đổi cần cân nhắc

Khẳng định hệ quả, không khẳng định lời gọi — trừ khi lời gọi là hệ quả. Gửi thư, ghi log kiểm toán, gọi cổng thanh toán: ở đó lời gọi chính là thứ cần kiểm, và mock là đúng. Nhưng hãy khẳng định đúng cái cần — "đã gửi một thư tới địa chỉ này" — không khẳng định "đã gọi GetCustomer hai lần rồi mới gửi". Mock hợp lý khi nó mỏng; mock đóng đinh toàn bộ chuỗi là bản sao của mã sản phẩm viết bằng kỳ vọng.

Fake cần được tin, nên fake cũng cần test. Một fake có luật ("sự kiện cho đơn không tồn tại là lỗi") có thể sai luật; khi fake và thật lệch nhau, test xanh mà production đỏ. Cách phổ biến: một bộ contract test chạy cùng một bộ ca trên cả fake lẫn cài đặt thật (với cơ sở dữ liệu ở phần 4) để bảo đảm hai bên cùng hành xử.

Stub vẫn có chỗ: cho phụ thuộc chỉ cung cấp dữ liệu (cấu hình, tỉ giá, đồng hồ) chứ không nhận tác dụng phụ. Ở đó không có hệ quả nào để kiểm, và stub là rẻ nhất.

Phạm vi đo. Bốn ca mỗi bộ, năm refactor và bốn lỗi do tôi chọn — chọn khác, con số khác; nhưng cơ chế thì không: mock vỡ theo số điểm tương tác bị đóng đinh, stub lọt theo số tác dụng phụ không được kiểm. Mock ở đây viết tay; thư viện mock (gomock, testify/mock) đổi cú pháp chứ không đổi bản chất "khẳng định lời gọi".

Ba ý mang về

  1. Ba thứ giả, ba hệ quả: stub xanh qua 4/4 refactor nhưng lọt 3/4 lỗi tác dụng phụ; mock bắt 4/4 lỗi nhưng vỡ 4/4 refactor (6 test phải sửa) vì nó đóng đinh cách gọi; fake bắt 4/4 và vỡ 0 vì nó giữ hệ quả.
  2. Khẳng định trạng thái sau khi chạy, không khẳng định chuỗi lời gọi, trừ khi lời gọi chính là hệ quả cần kiểm — và khi đó chỉ khẳng định đúng lời gọi đó.
  3. Double viết tay trả giá theo kích thước interface: thêm một phương thức là ba bộ test cùng không biên dịch. Giữ interface nhỏ theo chỗ dùng, hoặc nhúng interface vào struct giả.

Nguồn

Phần sau đưa cái thật vào: kiểm thử tích hợp với PostgreSQL thật — container tạm hay cơ sở dữ liệu dùng chung, rollback mỗi test hay dọn bằng tay, migration chạy ở đâu — và đo thời gian thiết lập cùng những lỗi mà SQLite hay fake không bao giờ thấy.