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

Ảnh chụp đoạn mã nền tối contract testing consumer nói nó cần gì provider kiểm trước khi deploy với Go 1.23 provider GET orders id hai consumer billing và notifier hợp đồng là JSON do test của consumer sinh ra, hai consumer hai nhu cầu khác nhau trên cùng một phản hồi gồm billing OrderView struct với ID con trỏ int64 json id TotalCents con trỏ int64 json total_cents Status con trỏ string json status, notifier OrderView struct với ID CustomerID json customer_id CreatedAt json created_at, ghi chú trường con trỏ là bắt buộc nil sau khi giải mã thành thiếu trường sai kiểu thành lỗi giải mã, hợp đồng suy từ chính struct consumer dùng xuất ra file trong test của consumer gồm contract.FromView billing orders id OrderView Write contracts tạo contracts billing.json với consumer billing path orders id fields id number status string total_cents number, ghi chú consumer test chạy với provider giả mock trả đúng hình dạng nó mong xanh dù provider thật có đổi gì, phía provider mỗi hợp đồng đã nộp được kiểm với handler thật trong CI của provider gồm vòng lặp glob contracts json body bằng GET httptest provider.Handler orders 1 rồi với mỗi name want trong c.Fields chỉ kiểm trường consumer cần nếu thiếu thì t.Error thiếu trường nếu jsonType khác want thì t.Error trường kiểu got hợp đồng cần want, so sánh snapshot chụp toàn bộ hình dạng phản hồi gốc khác bất kỳ gì cũng đỏ và e2e hai consumer gọi provider thật trong một tiến trình, năm thay đổi phía provider PROV_VARIANT đội provider sửa test của mình theo gồm 1 đổi tên total_cents thành total 2 thêm currency 3 status thành số 4 bỏ created_at 5 id thành chuỗi

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ì

Ảnh chụp output thật nền tối hợp đồng bắt đúng 3 trên 3 phá vỡ nêu đúng consumer 0 báo oan với go test trong go-lab mỗi cột một cách phát hiện mỗi hàng một thay đổi của provider, ai đỏ khi provider đổi gì gồm không đổi xanh ở cả năm cột, 1 đổi tên total_cents thành total test provider xanh test consumer xanh hợp đồng ĐỎ billing snapshot ĐỎ e2e ĐỎ billing, 2 thêm currency vô hại xanh xanh hợp đồng xanh snapshot ĐỎ báo oan e2e xanh, 3 status thành số xanh xanh hợp đồng ĐỎ billing snapshot ĐỎ e2e ĐỎ billing, 4 bỏ created_at xanh xanh hợp đồng ĐỎ notifier snapshot ĐỎ e2e ĐỎ notifier, 5 id thành chuỗi xanh xanh hợp đồng ĐỎ billing notifier snapshot ĐỎ e2e ĐỎ billing notifier, tổng bắt phá vỡ trên báo oan test provider 0 trên 4 và 0, test consumer 0 trên 4 và 0, hợp đồng 4 trên 4 và 0, snapshot 4 trên 4 và 1, e2e 4 trên 4 và 0, vì sao hai cột đầu xanh hết gồm test provider đội provider đổi API thì sửa test theo nên test chỉ xác nhận điều họ vừa làm, test consumer mock đóng băng hình dạng cũ nên consumer xanh mãi cho tới khi deploy cạnh provider mới, cùng lỗi ba cách nói gồm hợp đồng 1-rename thiếu trường total_cents là tên trường consumer nào, hợp đồng 3-status-int trường status kiểu number hợp đồng cần string, snapshot 2-add hình dạng khác bản chụp created_at currency customer_id đỏ cả khi không ai bị ảnh hưởng, e2e 3-status-int json cannot unmarshal number into Go struct field OrderView.status cần mã của cả hai bên, thời gian count 50 hợp đồng 0,012 s e2e 0,013 s như nhau khi cùng tiến trình khác ở chỗ chạy và ai chạy, nói thẳng e2e bắt được y hệt hợp đồng khi bạn có mã của mọi consumer trong tay và chạy được chúng cạnh provider hợp đồng là cách đưa đúng phần đó trường nào kiểu gì sang CI của provider mà không cần mã consumer

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ề

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

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.