Bảy phần trước đều ở trong một gói hoặc một dịch vụ. Lỗi đắt nhất trong backend hiện đại lại nằm giữa các dịch vụ: đội A đổi tên một trường JSON, test của A xanh, test của B xanh, deploy xong B sập. Hai bộ test xanh là vì chúng kiểm hai thế giới riêng: A kiểm điều A vừa làm, B kiểm với một bản giả của A mà B tự viết. Contract testing lấp khoảng trống đó bằng một tài liệu nhỏ mà B nộp cho A — "tôi cần những trường này, kiểu này" — và A kiểm nó trong CI của mình trước khi deploy. Bài này đo xem cơ chế đó bắt được gì, bỏ qua gì, và so với hai cách khác: chụp toàn bộ phản hồi, và chạy end-to-end.
Provider, hai consumer, và hợp đồng

Hình 1: Hợp đồng được suy từ struct mà consumer dùng để giải mã, nên nó chỉ chứa đúng những trường consumer thật sự đọc. Provider kiểm nó với handler thật.
Provider là một handler GET /orders/{id} trả năm trường: id, customer_id, total_cents, status, created_at. Hai consumer đọc nó theo hai cách:
- billing cần
id,total_cents,statusđể tính tiền. - notifier cần
id,customer_id,created_atđể gửi thông báo.
Mỗi consumer giải mã vào một struct với các trường con trỏ: trường thiếu thì con trỏ nil, trường sai kiểu thì lỗi giải mã — nên consumer thật sự vỡ khi provider đổi, không im lặng bỏ qua.
Hợp đồng được suy bằng reflection từ chính struct đó: tên trường JSON và kiểu (number, string, bool), ghi ra contracts/billing.json ngay trong test của consumer. Test của consumer chạy với một provider giả trả đúng hình dạng consumer mong — như mọi test consumer ngoài đời. Phía provider, một test đọc mọi file hợp đồng đã nộp, gọi handler thật, và kiểm từng trường trong hợp đồng: có mặt không, đúng kiểu không. Chỉ những trường consumer cần — trường khác provider muốn làm gì thì làm.
Hai cách đối chứng: snapshot chụp hình dạng toàn bộ phản hồi gốc và đỏ khi có bất kỳ khác biệt; e2e cho hai consumer gọi provider thật trong một tiến trình. Rồi năm thay đổi phía provider, mỗi lần đội provider cũng sửa test của mình cho khớp — vì đó là điều họ làm thật: đổi tên total_cents thành total, thêm currency, status thành số, bỏ created_at, id thành chuỗi.
Đo thật: ai đỏ khi provider đổi gì

Hình 2: Năm thay đổi, năm cách phát hiện. Hai cột đầu xanh toàn bộ là chuyện bình thường của hai đội làm việc riêng.
| thay đổi | test provider | test consumer | hợp đồng | snapshot | e2e |
|---|---|---|---|---|---|
đổi tên total_cents |
xanh | xanh | billing | đỏ | billing |
thêm currency |
xanh | xanh | xanh | đỏ (oan) | xanh |
status thành số |
xanh | xanh | billing | đỏ | billing |
bỏ created_at |
xanh | xanh | notifier | đỏ | notifier |
id thành chuỗi |
xanh | xanh | billing, notifier | đỏ | billing, notifier |
Hai cột đầu xanh hết, và đó không phải lỗi của ai. Đội provider đổi status thành số thì sửa test của mình cho khớp — test của họ xác nhận điều họ vừa làm, đúng chức năng của nó. Test của consumer chạy với mock trả hình dạng cũ — cũng đúng chức năng: kiểm logic của consumer, không kiểm provider. Cả hai bộ test đều làm đúng việc, và không bộ nào biết hai bên vừa lệch nhau. Lỗi chỉ lộ khi hai dịch vụ gặp nhau thật — trên staging nếu may, trên production nếu không.
Hợp đồng bắt đúng 4/4 phá vỡ, nêu đúng consumer, và im lặng đúng chỗ. Đổi tên total_cents: "thiếu trường total_cents", chỉ billing — notifier không dùng trường đó nên không bị nhắc. status thành số: "trường status kiểu number, hợp đồng cần string", billing. Bỏ created_at: notifier, không phải billing. id thành chuỗi: cả hai, vì cả hai cần id là số. Và thêm currency: xanh — không consumer nào cần nó nên không ai bị ảnh hưởng, provider được tự do thêm. Thông báo lỗi của hợp đồng nói đúng ba điều người sửa cần: trường nào, sai gì, consumer nào — trước khi deploy, trong CI của chính provider, không cần mã của consumer.
Snapshot bắt 4/4 nhưng báo oan 1. Chụp toàn bộ hình dạng phản hồi rồi so sánh là cách nhiều đội làm vì dễ: một golden file. Nó đỏ đúng với bốn phá vỡ — và đỏ luôn với currency, một thay đổi không làm ai vỡ. Mỗi lần provider thêm trường là một lần ai đó phải "cập nhật golden", và sau vài lần người ta cập nhật golden theo phản xạ, kể cả khi thay đổi là phá vỡ thật. Snapshot cũng không nói consumer nào bị ảnh hưởng, vì nó không biết có consumer nào.
E2e bắt y hệt hợp đồng — với một điều kiện lớn. Cột e2e giống cột hợp đồng từng ô, vì bản chất là cùng phép kiểm: consumer thật đọc phản hồi thật. Khác biệt không nằm ở kết quả mà ở điều kiện chạy: e2e cần mã của billing, notifier và provider trong cùng một chỗ, cùng lúc; ba đội, ba repo, ba lịch deploy thì "cùng một chỗ" là chuyện hiếm. Hợp đồng là cách tách đúng phần cần thiết của consumer — trường nào, kiểu gì — thành một file nhỏ để provider chạy một mình. Thời gian ngang nhau (0,012 so với 0,013 giây cho 50 lượt) vì cả hai ở cùng tiến trình; khác biệt thật là ai chạy được và khi nào.
Thông báo lỗi cũng khác. Hợp đồng: "thiếu trường total_cents" ở consumer billing. E2e: json: cannot unmarshal number into Go struct field OrderView.status of type string — đúng, nhưng là lỗi của thư viện JSON, người đọc phải lần ngược xem struct nào, consumer nào, và provider vừa đổi gì.
Đánh đổi cần cân nhắc
Hợp đồng chỉ chặt bằng cách consumer khai nhu cầu. Ở đây hợp đồng suy từ struct giải mã, nên nó biết trường và kiểu nhưng không biết giá trị: nếu provider đổi status từ "paid" thành "PAID", kiểu vẫn là string và hợp đồng xanh, nhưng billing so sánh == "paid" sẽ vỡ. Công cụ như Pact cho phép khai cả ví dụ giá trị và luật khớp (regex, tập giá trị); cái giá là hợp đồng dài hơn và dễ quá chặt — khai status == "paid" là cấm provider thêm trạng thái mới.
Hợp đồng phải đi từ consumer sang provider, và phải được chạy. File JSON nằm trong repo consumer thì vô dụng; cần một chỗ provider lấy được (Pact Broker, một bucket, hay đơn giản là commit sang repo provider) và một bước CI ở provider đọc nó. Phần hạ tầng này là chi phí thật, lớn hơn chi phí viết test.
Không thay cho e2e, cũng không thay cho test consumer. Hợp đồng kiểm hình dạng giao tiếp; logic của consumer vẫn cần test với mock (phần 3), hành vi của provider vẫn cần test của provider, và vài luồng chính vẫn cần e2e thật trên staging để bắt những gì không nằm trong JSON — xác thực, timeout, thứ tự gọi.
Phạm vi đo. Năm thay đổi do tôi chọn, trong đó bốn phá vỡ và một vô hại; hợp đồng chỉ kiểm tên và kiểu trường. Với API thật, phần lớn thay đổi phá vỡ đúng là kiểu đó — đổi tên, đổi kiểu, bỏ trường — nhưng không phải tất cả, và tỉ lệ bắt được ngoài đời sẽ thấp hơn 4/4.
Ba ý mang về
- Test của provider và test của consumer đều xanh khi hai bên lệch nhau, và đó là bình thường: mỗi bộ kiểm thế giới của riêng nó. 5/5 thay đổi, cả hai cột đều xanh.
- Hợp đồng consumer-driven bắt đúng phá vỡ (4/4), nêu đúng consumer, im lặng với thay đổi vô hại (0 báo oan) — vì nó chỉ chứa những gì consumer thật sự đọc. Snapshot toàn bộ bắt được nhưng báo oan và không biết ai bị ảnh hưởng.
- E2e cho cùng kết quả nhưng cần mã của mọi bên trong một chỗ; hợp đồng là cách mang đúng phần "trường nào, kiểu gì" sang CI của provider. Hạ tầng chuyển hợp đồng là chi phí chính, không phải mã test.
Nguồn
- Pact — Consumer-driven contract testing (tài liệu): https://docs.pact.io/
- Ian Robinson — Consumer-Driven Contracts: A Service Evolution Pattern (martinfowler.com, 2006): https://martinfowler.com/articles/consumerDrivenContracts.html
- Martin Fowler — ContractTest (bliki): https://martinfowler.com/bliki/ContractTest.html
- Pact Broker — chia sẻ hợp đồng giữa các đội và can-i-deploy: https://docs.pact.io/pact_broker
- Sam Newman — Building Microservices, 2nd ed. (2021), chương 9 Testing
Phần sau quay về vấn đề làm mọi bộ test mất uy tín: test giòn — đỏ rồi xanh mà không ai đổi gì. Tái hiện bốn nguyên nhân (thời gian, thứ tự, dữ liệu dùng chung, goroutine rò) và đo tỉ lệ giòn bằng cách chạy lặp 100 lần.