Chương 57. Contract testing

Ở chương trước, ta nói về testing để tự tin thay đổi hệ thống.

Unit test giúp kiểm tra logic nhỏ.

Integration test giúp kiểm tra các mảnh ghép.

End-to-end test giúp kiểm tra luồng người dùng quan trọng.

Nhưng khi hệ thống có nhiều service, ta gặp một vấn đề khác:

Mỗi service có thể test nội bộ đều pass.
Nhưng khi ghép lại, hệ thống vẫn chết.

Vì sao?

Vì service này thay đổi cách nói chuyện, còn service kia vẫn hiểu theo cách cũ.

Ví dụ:

Grading Service đổi field từ score sang final_score.
Frontend vẫn đọc score.
UI hiển thị điểm trống.

Hoặc:

Submission Service trước đây trả status = "graded".
Bây giờ trả status = "completed".
Teacher Dashboard vẫn đợi "graded".
Bài đã chấm nhưng dashboard tưởng chưa xong.

Đây là bài toán contract.

Contract testing giúp đảm bảo các service vẫn giữ lời hứa với nhau.

Thông điệp chính của chương:

> Trong hệ thống nhiều service, lỗi không chỉ nằm trong từng service. Lỗi thường nằm ở ranh giới giữa chúng. Contract testing biến "service này hứa sẽ nói chuyện như thế nào" thành test có thể chạy tự động.

---

57.1. Một tình huống: sửa API tưởng nhỏ, lỗi thật lớn

Hãy quay lại AI Judge.

Ta có vài thành phần:

Frontend.
Submission Service.
Grading Service.
Notification Service.
Teacher Dashboard.

Frontend gọi API để lấy kết quả chấm:

{
  "submission_id": "sub_123",
  "score": 8.5,
  "status": "graded",
  "feedback": "Bài làm tốt, cần giải thích thêm ở câu 2."
}

Một ngày, team backend muốn làm rõ hơn.

Họ đổi score thành final_score:

{
  "submission_id": "sub_123",
  "final_score": 8.5,
  "status": "graded",
  "feedback": "Bài làm tốt, cần giải thích thêm ở câu 2."
}

Trong Grading Service, test vẫn pass.

Vì code mới đúng theo logic mới.

Nhưng Frontend vẫn đọc:

response.score

Kết quả:

Điểm không hiển thị.
Giáo viên tưởng bài chưa có điểm.
Support nhận ticket.
Backend nói service vẫn chạy.
Frontend nói API đổi mà không báo.

Lỗi này không phải bug trong hàm tính điểm.

Cũng không hẳn là lỗi hạ tầng.

Nó là lỗi contract.

Hai bên không còn hiểu cùng một lời hứa.

---

57.2. Contract là gì?

Contract là hợp đồng giữa bên cung cấp và bên sử dụng.

Trong phần mềm, contract thường trả lời:

Endpoint nào tồn tại?
Request cần field gì?
Response trả field gì?
Field nào bắt buộc?
Field nào có thể null?
Status code nào có thể trả về?
Error format ra sao?
Giá trị enum gồm những gì?
Ý nghĩa của từng trạng thái là gì?

Ví dụ API contract:

GET /submissions/{id}/result

Response 200:
- submission_id: string, required
- status: queued | grading | graded | failed | needs_review
- score: number, nullable
- feedback: string, nullable
- graded_at: ISO datetime, nullable

Contract không chỉ là hình dạng JSON.

Nó còn là ý nghĩa.

Ví dụ:

Nếu status = "graded", score phải có giá trị.
Nếu status = "queued", score phải null.
Nếu status = "failed", error_code phải có.

Nếu chỉ nhìn schema, có thể ta biết field nào là string.

Nhưng nếu không hiểu ý nghĩa trạng thái, consumer vẫn có thể xử lý sai.

---

57.3. Producer và consumer

Trong contract testing, ta thường nói:

Producer.
Consumer.

Producer là bên cung cấp API hoặc message.

Consumer là bên sử dụng API hoặc message đó.

Ví dụ:

Grading Service là producer của API kết quả chấm.
Frontend là consumer.
Teacher Dashboard cũng là consumer.
Notification Service cũng có thể là consumer.

Một producer có thể có nhiều consumer.

Đây là lý do đổi API rất dễ nguy hiểm.

Backend team có thể nghĩ:

Field score không còn dùng nữa.
Đổi thành final_score cho rõ.

Nhưng thực tế:

Frontend dùng score.
Mobile app dùng score.
Report Service dùng score.
Một script export cũ dùng score.

Nếu không biết ai đang dùng gì, thay đổi rất dễ phá hệ thống.

Contract testing giúp làm rõ:

Consumer nào đang kỳ vọng điều gì từ producer?

---

57.4. Contract testing khác integration testing thế nào?

Integration test thường kiểm tra nhiều thành phần chạy cùng nhau.

Ví dụ:

Frontend test gọi backend thật.
Backend test gọi database thật.
Worker test với queue thật.

Contract test tập trung vào lời hứa giữa hai bên.

Nó không nhất thiết chạy toàn bộ hệ thống.

Ví dụ:

Frontend nói: tôi cần GET /result trả field score khi status = graded.
Contract test kiểm tra Grading Service còn đáp ứng kỳ vọng đó không.

Integration test hỏi:

Khi ghép các phần lại, luồng này có chạy không?

Contract test hỏi:

Service này có còn nói đúng ngôn ngữ mà consumer đang hiểu không?

Hai loại test bổ sung nhau.

Contract test không thay thế integration test.

Nhưng trong microservices, nó giúp giảm nhu cầu phải dựng toàn bộ hệ thống chỉ để kiểm tra mọi cặp service.

---

57.5. Vì sao microservices làm contract quan trọng hơn?

Trong monolith, nhiều thay đổi xảy ra trong cùng một codebase.

Nếu đổi tên field nội bộ, compiler hoặc test nội bộ có thể bắt lỗi nhanh hơn.

Trong microservices, mỗi service có thể:

Repository riêng.
Deploy riêng.
Team riêng.
Ngôn ngữ riêng.
Chu kỳ release riêng.

Khi Grading Service đổi API, Frontend hoặc Report Service có thể chưa deploy cùng lúc.

Thậm chí consumer có thể là bên thứ ba.

Vì vậy ta không thể giả định:

Đổi producer và consumer cùng một lần là xong.

Thực tế production thường có giai đoạn:

Producer version mới.
Consumer version cũ.
Consumer version mới.
Một số client mobile chưa cập nhật.
Một số job cũ vẫn đang xử lý message cũ.

Contract giúp hệ thống chịu được quá trình chuyển đổi đó.

Không phải chỉ trạng thái cuối cùng.

---

57.6. API documentation chưa đủ

Nhiều team nói:

Chúng tôi có OpenAPI/Swagger rồi.

Tốt.

Nhưng documentation không tự động đảm bảo production đúng.

Có vài vấn đề:

Docs có thể không cập nhật.
Code có thể trả khác docs.
Consumer có thể dùng một phần docs theo cách riêng.
Docs có thể thiếu ví dụ lỗi.
Docs thường mô tả producer, chưa chắc mô tả kỳ vọng thật của từng consumer.

OpenAPI rất hữu ích để mô tả API.

Nhưng contract testing muốn tiến thêm một bước:

Lời hứa đó có được kiểm tra tự động không?

Tài liệu là bản mô tả.

Contract test là bản mô tả có thể chạy.

Một contract không chạy trong CI rất dễ thành lời hứa cũ.

---

57.7. Schema compatibility là gì?

Schema compatibility là khả năng schema mới vẫn tương thích với bên đang dùng schema cũ.

Ví dụ response cũ:

{
  "submission_id": "sub_123",
  "score": 8.5
}

Response mới:

{
  "submission_id": "sub_123",
  "score": 8.5,
  "feedback": "Good work"
}

Thường đây là thay đổi tương thích.

Consumer cũ đọc score, bỏ qua feedback.

Nhưng nếu response mới là:

{
  "submission_id": "sub_123",
  "final_score": 8.5
}

thì consumer cũ đọc score sẽ hỏng.

Đó là breaking change.

Schema compatibility không chỉ áp dụng cho JSON HTTP API.

Nó cũng áp dụng cho:

Event schema.
Message queue payload.
RPC request/response.
Database view cho service khác đọc.
CSV export mà hệ thống khác import.
Webhook payload.

Bất cứ nơi nào có bên gửi và bên nhận đều có contract.

---

57.8. Backward compatibility là gì?

Backward compatibility nghĩa là phiên bản mới vẫn hỗ trợ consumer cũ.

Ví dụ:

API mới vẫn trả field score, dù có thêm final_score.

Consumer cũ vẫn chạy.

Consumer mới có thể chuyển dần sang final_score.

Sau khi biết không còn ai dùng score, producer mới có thể bỏ field cũ.

Backward compatibility rất quan trọng vì deploy không xảy ra cùng một lúc.

Một cách đổi an toàn:

1. Thêm field mới final_score, vẫn giữ score.
2. Consumer chuyển sang dùng final_score.
3. Theo dõi logs/metrics để biết score còn ai dùng không.
4. Thông báo deprecation.
5. Sau một thời gian, bỏ score.

Cách đổi nguy hiểm:

Đổi score thành final_score trong một deploy.
Hy vọng mọi consumer đã cập nhật.

Hy vọng không phải chiến lược deploy.

---

57.9. Breaking change là gì?

Breaking change là thay đổi làm consumer hiện tại hỏng hoặc hiểu sai dữ liệu.

Ví dụ:

Xóa field đang được dùng.
Đổi tên field.
Đổi kiểu dữ liệu.
Đổi ý nghĩa field.
Đổi enum value.
Đổi status code.
Đổi error format.
Biến field optional thành required trong request.
Biến field nullable thành non-null mà dữ liệu cũ không đáp ứng.
Đổi đơn vị từ seconds sang milliseconds nhưng giữ tên field.

Một số breaking change rất dễ thấy:

score -> final_score.

Một số breaking change rất khó thấy:

timeout_seconds trước đây tính bằng giây,
bây giờ tính bằng mili giây.

Schema nhìn vẫn là number.

Nhưng ý nghĩa đã đổi.

Consumer có thể không crash.

Nó chỉ xử lý sai.

Đây là loại lỗi nguy hiểm hơn crash.

Vì hệ thống có vẻ vẫn chạy.

---

57.10. Thay đổi nào thường an toàn hơn?

Một số thay đổi thường tương thích hơn:

Thêm field optional vào response.
Thêm endpoint mới.
Thêm enum value nếu consumer được thiết kế tolerant.
Mở rộng response mà consumer cũ bỏ qua field lạ.
Cho phép request thêm field optional.

Nhưng chữ "thường" rất quan trọng.

Thêm enum value có thể phá consumer nếu consumer viết:

Nếu status không nằm trong 3 giá trị đã biết, crash.

Thêm field cũng có thể phá nếu consumer validate response quá chặt và không cho field lạ.

Vì vậy consumer nên tolerant với dữ liệu không cần dùng.

Producer nên cẩn thận với dữ liệu consumer đang dùng.

Một nguyên tắc hay:

Producer giữ lời hứa chặt.
Consumer đọc rộng lượng.

Nghĩa là producer không tự ý phá contract.

Consumer không nên sụp chỉ vì thấy field mới không liên quan.

---

57.11. Consumer-driven contract là gì?

Consumer-driven contract, thường gọi là CDC, là cách để consumer mô tả kỳ vọng của mình với producer.

Thay vì producer nói chung chung:

API của tôi trông như thế này.

Consumer nói:

Khi tôi gọi endpoint này với input này,
tôi cần response có các field này,
với ý nghĩa này,
để tôi chạy được luồng của tôi.

Ví dụ Frontend tạo contract:

Khi GET /submissions/sub_123/result trả status = graded,
response phải có:
- submission_id
- status
- score
- feedback

Teacher Dashboard có thể có contract khác:

Khi lấy danh sách kết quả,
tôi cần submission_id, student_name, score, graded_at.

Notification Service có thể chỉ cần:

submission_id, status, student_id.

Như vậy producer biết chính xác consumer nào cần gì.

Không phải đoán.

---

57.12. Vì sao consumer-driven contract hữu ích?

Producer thường không biết hết cách consumer dùng API.

Docs có thể nói response có 20 field.

Nhưng consumer A chỉ dùng 3 field.

Consumer B dùng 5 field khác.

Consumer C phụ thuộc vào một error code cụ thể.

Nếu chỉ test producer theo docs, ta có thể bỏ sót expectation thật.

CDC đảo góc nhìn:

Consumer nói nhu cầu thật.
Producer phải chứng minh mình vẫn đáp ứng.

Điều này hữu ích khi:

Nhiều team cùng phát triển.
Service deploy độc lập.
API có nhiều consumer.
Breaking change từng xảy ra.
End-to-end test quá chậm.

CDC không cần kiểm tra mọi thứ producer có thể làm.

Nó kiểm tra những thứ consumer thật sự cần.

Vì vậy nó thường gọn hơn E2E nhưng sát rủi ro hơn unit test nội bộ.

---

57.13. Contract test chạy ở đâu?

Một luồng đơn giản:

Consumer viết contract.
Contract được lưu ở nơi producer truy cập được.
Producer CI chạy contract test.
Nếu producer đổi API làm contract fail, build fail.

Ví dụ:

Frontend contract nói score là required khi status = graded.
Grading Service đổi bỏ score.
CI của Grading Service chạy contract.
Test fail.
Team biết trước khi deploy.

Đây là giá trị lớn nhất:

Phát hiện gãy contract trước production.

Contract test có thể chạy trong:

CI của producer.
CI của consumer.
Pipeline trước deploy.
Nightly build.

Quan trọng là nó phải chạy ở điểm có thể chặn thay đổi nguy hiểm.

Nếu contract test chỉ chạy sau khi deploy production, nó mất nhiều giá trị.

---

57.14. Provider verification

Trong contract testing, provider verification là bước producer chứng minh:

Tôi đáp ứng contract mà consumer đã khai báo.

Ví dụ consumer contract:

Request:
GET /submissions/sub_123/result

Expected response:
status 200
body.status = "graded"
body.score là number
body.feedback là string

Provider verification sẽ chạy Grading Service trong môi trường test.

Sau đó tạo trạng thái phù hợp:

submission sub_123 đã graded.

Rồi gọi endpoint thật.

Nếu response đúng, verification pass.

Nếu response thiếu score, verification fail.

Điểm khó không phải gọi endpoint.

Điểm khó là chuẩn bị provider state.

Tức là đưa service vào đúng trạng thái để test contract.

---

57.15. Provider state là gì?

Provider state là trạng thái cần có trước khi kiểm tra contract.

Ví dụ consumer muốn test:

Khi submission đã graded, API trả score.

Provider phải tạo dữ liệu:

User tồn tại.
Assignment tồn tại.
Submission tồn tại.
Grading result tồn tại.
Status = graded.

Nếu không có provider state rõ ràng, contract test sẽ flaky hoặc khó chạy.

Một lỗi hay gặp:

Contract test phụ thuộc vào dữ liệu có sẵn trong database test.

Hôm nay có dữ liệu nên pass.

Mai database reset nên fail.

Provider state nên được tạo rõ trong test setup.

Contract test tốt phải deterministic.

Nghĩa là cùng code, cùng input, kết quả ổn định.

---

57.16. Contract không chỉ là success response

Nhiều team chỉ contract test happy path.

Ví dụ:

GET result thành công.

Nhưng consumer cũng phụ thuộc vào error contract.

Ví dụ:

{
  "error_code": "SUBMISSION_NOT_FOUND",
  "message": "Submission not found"
}

Frontend có thể dựa vào error_code để hiển thị:

Bài nộp không tồn tại hoặc bạn không có quyền truy cập.

Nếu backend đổi error format thành:

{
  "detail": "not found"
}

Frontend có thể không xử lý đúng.

Vì vậy contract nên bao gồm cả:

404.
403.
422.
429.
500 nếu có format chuẩn.
Validation error.
Rate limit error.
Permission error.

Error contract rất quan trọng với trải nghiệm user.

API thành công chỉ là một nửa câu chuyện.

---

57.17. Contract cho request

Không chỉ response mới có contract.

Request cũng có contract.

Ví dụ Frontend gửi:

{
  "assignment_id": "ass_123",
  "file_ids": ["file_1"],
  "submitted_at": "2026-05-12T10:00:00Z"
}

Submission Service kỳ vọng:

assignment_id là required.
file_ids là list.
submitted_at là ISO datetime.

Nếu backend bỗng yêu cầu thêm field required:

submission_type

Frontend cũ sẽ fail.

Đây là breaking change.

Thêm required field vào request thường nguy hiểm hơn thêm optional field vào response.

Vì consumer cũ không biết phải gửi gì.

Cách an toàn hơn:

Thêm field optional.
Đặt default phía server.
Cho consumer mới gửi field.
Sau khi mọi consumer cập nhật, mới cân nhắc bắt buộc.

---

57.18. Enum là nơi hay gãy contract

Enum nhìn có vẻ đơn giản.

Ví dụ:

status = queued | grading | graded | failed

Sau đó ta thêm:

needs_review

Nếu consumer viết tốt, nó có thể xử lý status lạ bằng trạng thái chung:

Đang cần xử lý thêm.

Hoặc:

Không biết trạng thái này, hiển thị fallback an toàn.

Nếu consumer viết cứng:

Nếu không phải queued/grading/graded/failed thì crash.

thêm enum value sẽ làm hỏng.

Vì vậy khi thiết kế contract, cần nói rõ:

Consumer có phải chịu được enum value mới không?
Producer có được thêm enum value mà không báo không?
UI fallback khi status lạ là gì?

Enum không chỉ là dữ liệu.

Nó thường điều khiển workflow.

Nên phải rất cẩn thận.

---

57.19. Null và missing field không giống nhau

Trong API, ba trạng thái này khác nhau:

Field có giá trị.
Field có nhưng null.
Field không tồn tại.

Ví dụ:

{
  "score": null
}

có thể nghĩa là:

Bài chưa có điểm.

Còn thiếu hẳn field score có thể làm consumer hiểu là API cũ/hỏng.

Hoặc ngược lại, tùy contract.

Điểm quan trọng:

Phải định nghĩa rõ.

Ví dụ contract tốt:

score luôn xuất hiện.
Nếu chưa graded, score = null.
Nếu graded, score là number.

Như vậy consumer dễ xử lý.

Contract mơ hồ:

score có thể có hoặc không.

Consumer sẽ phải đoán.

Đoán ở ranh giới service thường tạo bug.

---

57.20. Versioning API

Khi cần breaking change thật, một cách phổ biến là versioning.

Ví dụ:

/api/v1/submissions/{id}/result
/api/v2/submissions/{id}/result

Hoặc:

Header: API-Version: 2

Versioning giúp consumer cũ tiếp tục dùng v1.

Consumer mới chuyển sang v2.

Producer có thời gian migration.

Nhưng versioning cũng có giá.

Ta phải duy trì nhiều phiên bản.

Bug fix có thể phải làm ở cả v1 và v2.

Docs phức tạp hơn.

Monitoring phải biết version nào còn traffic.

Vì vậy đừng version hóa vì mọi thay đổi nhỏ.

Nên ưu tiên backward-compatible change trước.

Chỉ dùng version mới khi thật sự cần phá hợp đồng cũ.

---

57.21. Deprecation là gì?

Deprecation nghĩa là thông báo một phần API sẽ bị bỏ trong tương lai, nhưng chưa bỏ ngay.

Ví dụ:

Field score sẽ được thay bằng final_score.
score vẫn tồn tại trong 90 ngày.
Consumer nên chuyển sang final_score.

Deprecation tốt cần có:

Thông báo rõ.
Thời hạn rõ.
Lý do rõ.
Hướng migration.
Metrics xem ai còn dùng field/endpoint cũ.
Ngày remove dự kiến.

Deprecation kém:

Gửi một tin nhắn trong chat rồi hy vọng mọi người nhớ.

Trong hệ thống nội bộ, deprecation vẫn quan trọng.

Không phải vì là team nhà mà có thể phá nhau thoải mái.

Professional system cần thay đổi có lịch sự với consumer.

---

57.22. Làm sao biết field cũ còn ai dùng?

Đây là câu hỏi khó.

Nếu consumer là frontend web duy nhất, có thể dễ hơn.

Nếu có mobile app, service nội bộ, script cũ, đối tác bên ngoài, sẽ khó hơn.

Một số cách:

Log request theo client_id.
Yêu cầu consumer gửi header nhận diện.
Theo dõi endpoint version.
Metric theo API version.
Audit contract trong repository.
Deprecation warning trong response header.
Trao đổi với owner consumer.

Với field trong response, khó biết consumer có đọc field đó không.

Vì server chỉ biết mình đã gửi.

Nó không biết client dùng field nào.

Vì vậy contract registry hoặc consumer-driven contract giúp ích.

Consumer khai báo:

Tôi đang cần field score.

Producer không phải đoán.

---

57.23. Contract registry

Contract registry là nơi lưu và quản lý contract giữa producer và consumer.

Nó có thể là tool chuyên dụng.

Hoặc đơn giản hơn:

Một repository chứa contract files.
Một folder trong monorepo.
Một package versioned.
Một hệ thống CI publish contract.

Điểm quan trọng không phải tool.

Điểm quan trọng là:

Contract có chủ sở hữu.
Contract có version.
Producer có thể chạy verification.
Consumer có thể cập nhật contract.
Breaking change được phát hiện.

Nếu contract nằm rải rác trong máy từng người, nó không giúp được nhiều.

Contract phải trở thành một phần của workflow phát triển.

---

57.24. Contract testing cho webhook

Webhook cũng là contract.

Ví dụ AI Judge gửi webhook cho hệ thống trường học:

{
  "event_type": "grading.completed",
  "submission_id": "sub_123",
  "score": 8.5,
  "graded_at": "2026-05-12T10:30:00Z"
}

Nếu ta đổi payload:

score -> final_score

hệ thống trường học có thể hỏng.

Webhook còn khó hơn API bình thường ở chỗ:

Consumer có thể nằm ngoài công ty.
Ta không deploy được consumer.
Ta không biết họ update nhanh không.
Họ có thể retry webhook theo cách riêng.

Với webhook, contract cần rất rõ:

Event type.
Payload schema.
Idempotency key.
Retry behavior.
Signature.
Timestamp.
Error handling.
Versioning.

Webhook không nên được đổi tùy tiện.

Với bên ngoài, backward compatibility còn quan trọng hơn.

---

57.25. Contract cho event-driven system

Event cũng có contract.

Ví dụ:

{
  "event_type": "submission.created",
  "event_id": "evt_123",
  "submission_id": "sub_123",
  "assignment_id": "ass_123",
  "student_id": "stu_123",
  "created_at": "2026-05-12T10:00:00Z"
}

Consumer có thể là:

Grading Worker.
Notification Service.
Analytics Service.
Audit Service.

Nếu event đổi schema, nhiều consumer có thể bị ảnh hưởng.

Event contract cần quan tâm thêm:

Event có thể đến trùng không?
Event có thể đến muộn không?
Event có thứ tự không?
Field nào bất biến?
Event version là gì?
Consumer cũ xử lý event mới ra sao?

Chương sau sẽ nói sâu về testing event-driven systems.

Ở đây chỉ cần nhớ:

Event payload cũng là API.

Chỉ khác là nó đi qua queue/broker thay vì HTTP request trực tiếp.

---

57.26. Contract testing không nên kiểm tra logic nội bộ

Contract test không nên biến thành test toàn bộ business logic của producer.

Ví dụ không nên đưa vào contract:

Thuật toán chấm điểm chi tiết.
Tất cả rule tính penalty.
Toàn bộ trạng thái nội bộ.
Tên bảng database.
Tên hàm xử lý.

Contract nên kiểm tra ranh giới:

Request.
Response.
Status code.
Error format.
Field required/optional.
Ý nghĩa trạng thái mà consumer phụ thuộc.

Nếu contract quá chi tiết, producer rất khó refactor.

Mỗi thay đổi nội bộ đều làm contract fail.

Khi đó contract không còn bảo vệ giao tiếp.

Nó đóng băng implementation.

Contract tốt bảo vệ lời hứa bên ngoài, không khóa tay bên trong.

---

57.27. Khi nào contract test đáng dùng?

Contract test đáng dùng khi:

Có nhiều service giao tiếp với nhau.
Service deploy độc lập.
API có nhiều consumer.
Nhiều team cùng phát triển.
Breaking change từng gây sự cố.
End-to-end test toàn hệ thống quá chậm.
Webhook hoặc public API có consumer bên ngoài.
Event schema được nhiều consumer dùng.

Ví dụ AI Judge nên cân nhắc contract test cho:

Frontend <-> Submission API.
Frontend <-> Grading Result API.
Teacher Dashboard <-> Report API.
Grading Worker <-> Submission event.
Notification Service <-> grading.completed event.
Webhook gửi sang LMS bên ngoài.

Những ranh giới này nếu gãy sẽ ảnh hưởng thật.

Contract test giúp bắt sớm.

---

57.28. Khi nào chưa cần contract test?

Không phải hệ thống nào cũng cần contract test ngay.

Nếu hệ thống còn là monolith nhỏ, một team, một repo, deploy cùng lúc, integration test đã đủ tốt, contract test có thể chưa cần.

Nếu API chỉ có một consumer duy nhất trong cùng codebase, contract test riêng có thể thừa.

Nếu contract thay đổi liên tục vì sản phẩm còn thử nghiệm rất sớm, viết contract quá chặt có thể làm chậm học hỏi.

Nhưng ngay cả khi chưa dùng contract testing formal, vẫn nên có tư duy contract:

Đổi API có phá ai không?
Field nào consumer đang dùng?
Response lỗi có ổn định không?
Có backward compatibility không?
Có thông báo deprecation không?

Tool có thể đến sau.

Tư duy nên có sớm.

---

57.29. Contract testing và TypeScript/shared types

Một số team dùng shared types.

Ví dụ:

Backend export type ResultResponse.
Frontend import type đó.

Điều này hữu ích.

Nó giúp frontend và backend cùng hiểu schema.

Nhưng shared types không giải quyết hết.

Vì:

Runtime response có thể khác type.
Service khác ngôn ngữ không dùng được type đó.
Consumer bên ngoài không import type.
Type không luôn mô tả error behavior.
Type không chắc nói rõ backward compatibility.

Shared types tốt cho monorepo hoặc hệ thống cùng stack.

Contract testing vẫn hữu ích khi service deploy độc lập hoặc consumer đa dạng.

Đừng xem chúng là đối thủ.

Chúng là các lớp bảo vệ khác nhau.

---

57.30. Contract testing và OpenAPI

OpenAPI có thể là nguồn contract.

Ta có thể dùng OpenAPI để:

Sinh client.
Validate request/response.
Tạo docs.
Chạy schema test.
Phát hiện breaking change giữa hai version.

Nhưng OpenAPI thường là producer-driven.

Nó mô tả API từ góc nhìn producer.

Consumer-driven contract bổ sung bằng cách nói:

Consumer này thật sự cần scenario này.

Trong thực tế, hai cách có thể kết hợp:

OpenAPI mô tả toàn bộ API.
Contract test kiểm tra các scenario consumer quan trọng.
CI kiểm tra response thật khớp OpenAPI.
CI kiểm tra producer không phá CDC.

Một lần nữa, điểm chính không phải chọn một phe.

Điểm chính là giảm rủi ro ở ranh giới service.

---

57.31. Một quy trình đổi API an toàn

Giả sử ta muốn đổi score thành final_score.

Cách nguy hiểm:

Đổi response.
Deploy.
Chờ xem có ai kêu không.

Cách an toàn hơn:

1. Thêm final_score, vẫn giữ score.
2. Cập nhật docs/contract.
3. Consumer mới chuyển sang final_score.
4. Producer log/metric xem consumer nào còn dùng version cũ.
5. Đánh dấu score deprecated.
6. Đợi hết thời gian migration.
7. Chạy contract để chắc không còn consumer cần score.
8. Bỏ score trong version mới hoặc breaking release.

Nếu có contract test, bước 7 đáng tin hơn nhiều.

Nếu contract của Frontend vẫn nói cần score, producer không được xóa.

Nếu Frontend cập nhật contract không cần score nữa, producer mới có thể tiến tiếp.

Contract biến quá trình đổi API thành quy trình có bằng chứng.

---

57.32. Một quy trình thêm field required an toàn

Giả sử Submission API muốn request thêm:

submission_type

Nếu thêm required ngay, consumer cũ hỏng.

Cách an toàn hơn:

1. Thêm submission_type là optional.
2. Server đặt default nếu thiếu.
3. Consumer mới bắt đầu gửi submission_type.
4. Theo dõi tỷ lệ request thiếu submission_type.
5. Khi gần như không còn consumer cũ, thông báo deprecation cho request thiếu field.
6. Sau thời gian migration, mới biến field thành required trong version mới.

Với public API, bước này càng cần chậm và rõ.

Với internal API, vẫn nên có kỷ luật.

Vì internal API cũng có production user phía sau.

---

57.33. Contract test nên được đặt tên theo hành vi

Tên contract nên nói rõ consumer cần gì.

Ví dụ kém:

result_api_contract_1

Ví dụ tốt:

frontend_can_render_graded_submission_result
teacher_dashboard_can_show_failed_submission_reason
notification_service_can_send_grading_completed_message

Tên tốt giúp producer hiểu:

Nếu test này fail, ai bị ảnh hưởng?
Luồng nào bị ảnh hưởng?
Có thể hỏi owner nào?

Contract không chỉ là file kỹ thuật.

Nó là điểm giao tiếp giữa team.

Tên mơ hồ làm giao tiếp khó hơn.

---

57.34. Contract fail thì xử lý thế nào?

Khi contract fail, có vài khả năng:

Producer đang phá consumer thật.
Consumer contract đã lỗi thời.
Contract quá chặt.
Provider state trong test sai.
Test setup không ổn định.

Không nên phản xạ đầu tiên là:

Xóa contract cho pass.

Nên hỏi:

Consumer còn cần hành vi này không?
Nếu còn, producer phải giữ.
Nếu không còn, consumer owner cập nhật contract.
Nếu contract quá chặt, nới contract về đúng lời hứa bên ngoài.
Nếu provider state sai, sửa setup.

Contract test là cuộc đối thoại tự động.

Khi nó fail, nghĩa là một lời hứa đang bị nghi ngờ.

Phải làm rõ lời hứa, không phải bịt tiếng chuông.

---

57.35. Contract quá chặt và contract quá lỏng

Contract quá chặt gây khó refactor.

Ví dụ:

Response phải có đúng thứ tự field.
Response không được có field mới.
Internal debug field phải bằng giá trị cụ thể.

Những thứ này có thể không quan trọng với consumer.

Contract quá lỏng lại không bảo vệ đủ.

Ví dụ:

Response là object bất kỳ.
score có thể là gì cũng được.
status là string bất kỳ.

Test pass nhưng consumer vẫn có thể hỏng.

Contract tốt nằm giữa:

Chặt với thứ consumer phụ thuộc.
Rộng lượng với thứ consumer không quan tâm.

Ví dụ:

score phải là number khi status = graded.
Response có thể có thêm field khác.

Đó là lời hứa vừa đủ.

---

57.36. Contract testing không thay thế giao tiếp giữa team

Contract test giúp phát hiện thay đổi nguy hiểm.

Nhưng nó không thay thế việc nói chuyện.

Nếu producer muốn bỏ một field quan trọng, vẫn cần:

Thông báo consumer.
Thống nhất timeline.
Có migration plan.
Có owner.
Theo dõi adoption.

Contract test làm việc tốt nhất khi có văn hóa rõ:

Không phá API âm thầm.
Consumer khai báo nhu cầu thật.
Producer tôn trọng backward compatibility.
Breaking change có quy trình.

Tool không cứu được một team đổi contract tùy hứng.

Nhưng tool giúp một team có kỷ luật làm việc nhẹ hơn.

---

57.37. AI Judge nên dùng contract testing ở đâu?

Với AI Judge, ta có thể bắt đầu từ các ranh giới có rủi ro cao.

Frontend với Submission API:

Tạo submission.
Upload file.
Xem trạng thái.
Xử lý validation error.

Frontend với Grading Result API:

Hiển thị queued/grading/graded/failed/needs_review.
Hiển thị score/feedback.
Hiển thị error rõ.

Teacher Dashboard với Report API:

Danh sách điểm.
Filter theo assignment.
Export trạng thái.

Notification Service với event grading.completed:

submission_id.
student_id.
assignment_id.
score.
graded_at.

Webhook ra hệ thống LMS:

Event type.
Payload version.
Signature.
Retry/idempotency key.

Không cần contract test mọi endpoint ngay.

Bắt đầu từ nơi gãy là đau nhất.

Sau đó mở rộng dần.

---

57.38. Bảng nhìn nhanh

| Khái niệm | Hiểu đơn giản | |---|---| | Contract | Lời hứa giữa producer và consumer | | Producer | Service cung cấp API/event/webhook | | Consumer | Service hoặc client sử dụng dữ liệu đó | | Contract test | Test kiểm tra producer còn giữ lời hứa không | | Consumer-driven contract | Consumer khai báo kỳ vọng của mình | | Backward compatibility | Version mới vẫn không làm consumer cũ hỏng | | Breaking change | Thay đổi làm consumer hiện tại hỏng hoặc hiểu sai | | Schema compatibility | Schema mới/cũ còn tương thích không | | Deprecation | Báo trước thứ gì sẽ bị bỏ | | Provider verification | Producer chạy test để chứng minh đáp ứng contract |

---

57.39. Checklist trước khi đổi API

Trước khi đổi một API hoặc event payload, hãy hỏi:

  • Ai là consumer?
  • Consumer nào đang dùng field/endpoint này?
  • Thay đổi có backward-compatible không?
  • Có xóa field không?
  • Có đổi tên field không?
  • Có đổi kiểu dữ liệu không?
  • Có đổi ý nghĩa field không?
  • Có thêm required field vào request không?
  • Có thêm enum value không?
  • Consumer có chịu được enum mới không?
  • Error format có đổi không?
  • Có contract test cho consumer quan trọng không?
  • Có OpenAPI/schema cập nhật không?
  • Có deprecation plan không?
  • Có versioning nếu breaking change là bắt buộc không?
  • Có monitoring xem version cũ còn traffic không?

Nếu không trả lời được "ai bị ảnh hưởng", đừng vội deploy breaking change.

---

57.40. Kết luận của chương

Contract testing sinh ra từ một thực tế rất đơn giản:

Trong hệ thống nhiều service, mỗi service đúng một mình chưa đủ.
Chúng còn phải hiểu nhau đúng.

Contract là lời hứa ở ranh giới.

Producer hứa sẽ nhận request và trả response theo một cách nhất định.

Consumer dựa vào lời hứa đó để chạy.

Breaking change là khi lời hứa bị thay đổi theo cách làm consumer hỏng.

Backward compatibility giúp thay đổi mà không phá consumer cũ.

Consumer-driven contract giúp consumer nói rõ mình cần gì.

Provider verification giúp producer chứng minh mình vẫn đáp ứng.

Contract testing không thay thế unit test, integration test hay E2E test.

Nó bổ sung vào khoảng trống giữa các service.

Thông điệp cần nhớ:

> API, event, webhook đều là lời hứa. Nếu hệ thống có nhiều service deploy độc lập, đừng chỉ test từng service riêng lẻ. Hãy test cả lời hứa giữa chúng.

Ở chương tiếp theo, ta sẽ nói về testing event-driven systems: nơi event có thể đến trùng, đến muộn, mất thứ tự, retry nhiều lần, và consumer phải được test như một thành phần độc lập.