Contract Testing — Chặn 2 team sửa API phá nhau

Phong Hy

Hồi mới làm ở công ty cũ, tụi tui có một vụ drama kinh điển: team A sửa API của mình để thêm một cái field mới, kỹ sư thấy field cũ cũng không ai dùng nên... xoá luôn. Kết quả là team B đang gọi cái endpoint đó thì sáng hôm sau nhận cả đống alert 400 và 500. Chẳng ai sai cả, vì mỗi team chỉ test trong phạm vi của mình. Cái thiếu là một hợp đồng (contract) rõ ràng giữa hai bên — và đó là lúc contract testing lên sàn.

Contract testing là gì?

Contract testing là cách test hai dịch vụ dựa trên một bản "thoả thuận" chung về cách chúng giao tiếp với nhau, thay vì test chúng với nhau thật (integration test) hay cô lập hoàn toàn (unit test).

Ba cái test này hay bị nhầm lẫn, nên nhớ giùm:

  • Unit test — test một hàm bên trong service, không cần biết ai gọi mình.
  • Contract test — test "giấy cam kết": request phải gửi đúng shape, response phải trả đúng shape. Hai bên đều tự test dựa trên cùng một contract.
  • Integration test — test thật hai service nối nhau, chậm và mong manh vì cần cả môi trường.

Điểm mạnh nhất của contract test là không cần cả hai service chạy cùng lúc. Cái này cực kỳ quý khi mỗi team chỉ có thể chạy phần của mình.

Cách hoạt động — provider với consumer

Trong hệ sinh thái này có hai vai trò:

  • Consumer: bên gọi API (client).
  • Provider: bên trả lời (server).

Ý tưởng là: consumer viết ra cái kỳ vọng của mình (expectation), đóng gói thành một contract, rồi provider phải test lại với đúng contract đó. Nếu provider đổi response làm consumer bể, test của provider sẽ đỏ ngay trước khi deploy — chứ không phải sáng hôm sau team B mới phát hiện.

Ví dụ thực tế với Pact

Cái thư viện phổ biến nhất cho chuyện này là Pact. Consumer viết expectation kiểu:

// consumer_test.go
type Order struct {
    ID     string `json:"id"`
    Amount float64 `json:"amount"`
}

func TestGetOrderContract(t *testing.T) {
    pact := &dsl.Pact{
        Consumer: "web-app",
        Provider: "order-service",
    }
    pact.AddInteraction().
        UponReceiving("A request for order 42").
        WithRequest(dsl.Request{
            Method: "GET",
            Path:   dsl.Term("/orders/42", `\/orders\/[0-9]+`),
        }).
        WillRespondWith(dsl.Response{
            Status: 200,
            Headers: dsl.MapMatcher{"Content-Type": "application/json"},
            Body:   dsl.Match(&Order{ID: "42", Amount: 99.5}),
        })

    // pact tạo mock server, test request trả về đúng kỳ vọng
    pact.Verify(t)
}

Chạy test này, Pact sinh ra file contract (JSON). Chuyển file đó cho team provider, và phía provider chạy nó lại:

// provider_states_test.go
func TestProviderHonorsContract(t *testing.T) {
    pact.VerifyProvider(t, dsl.VerifyRequest{
        Provider:        "order-service",
        ProviderBaseURL: "http://localhost:8080",
        PactFiles: dsl.PactFile{
            Paths: []string{"./pacts/web-app-order-service.json"},
        },
    })
}

Giờ team provider sửa API mà quên giữ amount thì test sẽ đỏ ngay. Tuyệt chưa?

Kinh nghiệm thực chiến

Mấy cái mình đúc kết sau bao lần đau đầu:

  1. Contract test KHÔNG thay integration test. Nó chỉ kiểm tra "shape" của dữ liệu, không kiểm tra behavior thật sự khi hai service chạy chung. Vẫn cần integration test cho những flow quan trọng, nhưng ít thôi.
  2. Bắt đầu từ contract giữa team nhỏ. Đừng ôm hết cả hệ thống. Chọn 1-2 cặp service đau đầu nhất, làm cho quen rồi lan ra.
  3. Quản lý version contract. Contract cũng cần version. Khi thay đổi, phải có bước migrate chứ đừng sửa xoá đại như team A của tụi tui.
  4. Để CI chạy. Đó là nơi contract test phát huy hết công lực: mỗi pull request đều chạy lại, nên bug bị chặn ngay từ cửa ngõ chứ không đợi đến production.

Tóm lại, contract testing là lớp "van an toàn" cực kỳ rẻ và hiệu quả cho các hệ thống microservices hoặc bất kỳ chỗ nào nhiều team cùng đụng một API. Chi phí đầu tư ban đầu hơi tốn công một chút, nhưng so với cái đêm team B nhận cả loạt alert 400 thì... rẻ hơn nhiều lắm.