# Mock API của khách và viết contract test trong một buổi chiều

> Khách chỉ cần đổi tên một field là code tích hợp của bạn có thể hỏng mà không báo một lỗi nào, trừ khi có một bài test được viết riêng để bắt đúng khoảnh khắc đó.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-mock-api-va-contract-test/

Thử hình dung: thứ Hai bạn deploy xong integration với hệ thống đơn hàng của khách và mọi test đều xanh. Sang thứ Năm, đội backend bên khách đổi tên field `total` thành `amount` mà không báo ai. Code của bạn không crash mà chỉ âm thầm trả về `None`, và báo cáo doanh thu cuối tuần ra số 0.

Lỗi này không cần đến bug phức tạp. Nó chỉ cần hai đội cùng làm trên một điểm tích hợp mà không có gì ràng buộc thỏa thuận giữa hai bên. Với một FDE, rủi ro này càng đáng lo, vì API thường nằm bên phía khách: nhiều khả năng bạn không kiểm soát được lịch release của họ, và có khi cũng không được đọc code của họ.

Hướng dẫn này đi qua hai việc trong một buổi chiều. Việc đầu tiên là mock API của khách để bạn làm việc được ngay. Việc thứ hai là viết contract test để biết được ngay khi khách đổi API.

## Bạn sẽ dựng những gì?

Bạn sẽ có bốn file Python: một file contract, một mock server, một bài test phía bạn và một script verify chạy với API thật. Bạn cần Python 3, một terminal, và nếu có thể thì URL staging của một API thật để thử bước cuối.

Code dưới đây là **phiên bản tự viết và đã giản lược**, chỉ dùng thư viện chuẩn của Python, để bạn thấy rõ cơ chế bên trong. Trong dự án thật, Pact làm sẵn những việc này.

Theo tài liệu của Pact, đây là công cụ "code-first" để contract test cho cả tích hợp HTTP lẫn message, chủ yếu dành cho developer và tester có viết code. Cú pháp cụ thể của Pact bạn tra ở docs.pact.io; ở đây ta tự dựng lại cơ chế bằng tay để hiểu nó chạy thế nào.

## Bước 1: Ghi lại thỏa thuận trước khi viết code

Cơ chế của Pact như sau: test phía consumer (phía gọi API, tức là bạn) sinh ra một pact file mô tả từng tương tác. Ở đây ta viết tay file đó, với ví dụ giả định là API đơn hàng của khách:

```json
{
"consumer": "fde-integration",
"provider": "customer-orders-api",
"interactions": [
{
"description": "lấy đơn hàng tồn tại",
"request": {"method": "GET", "path": "/orders/42"},
"response": {"status": 200,
"body": {"id": 42, "status": "PAID", "total": 150000}}
},
{
"description": "đơn hàng không tồn tại",
"request": {"method": "GET", "path": "/orders/999"},
"response": {"status": 404, "body": {"error": "not_found"}}
}
]
}
```

Lưu thành `contract.json`. **Kiểm tra:** đưa file này cho kỹ sư bên khách đọc. Nếu họ xác nhận API trả về đúng như thế thì bạn đã có thỏa thuận bằng văn bản đầu tiên, và mọi chỗ hai bên hiểu lệch nhau sẽ lộ ra ngay ở bước này.

Ca 404 được đưa vào có chủ đích. Mock cho phép bạn test vượt ra ngoài các kịch bản thông thường, và ca lỗi là chỗ code tích hợp dễ vỡ mà ít ai test tới.

## Bước 2: Dựng mock từ chính contract

API mock là một web service mô phỏng service thật, và nó dùng được ngay từ giai đoạn đầu, khi service thật chưa sẵn sàng. Mock của ta sẽ đọc thẳng từ contract, để hai thứ này không bao giờ lệch nhau:

```python
# mock_server.py — bản giản lược, chỉ khớp theo path
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

CONTRACT = json.load(open("contract.json"))

class Mock(BaseHTTPRequestHandler):
def do_GET(self):
for it in CONTRACT["interactions"]:
if it["request"]["path"] == self.path:
res = it["response"]
self.send_response(res["status"])
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(res["body"]).encode())
return
self.send_response(500)
self.end_headers()

HTTPServer(("localhost", 8001), Mock).serve_forever()
```

```bash
python3 mock_server.py &
curl -i http://localhost:8001/orders/999
```

**Kiểm tra:** bạn phải thấy `HTTP/1.0 404` và body `{"error": "not_found"}`. Nếu gọi một path không có trong contract thì nhận về 500. Lỗi này có chủ đích: code của bạn đang gọi một thứ chưa có trong thỏa thuận.

## Bước 3: Test code của bạn trên mock

Đây là đoạn code tích hợp thật sự, cùng bài test của nó:

```python
# client.py
import json, urllib.request, urllib.error

def get_order_total(base_url, order_id):
try:
with urllib.request.urlopen(f"{base_url}/orders/{order_id}") as r:
return json.load(r).get("total")
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
```

```python
# test_consumer.py
from client import get_order_total
BASE = "http://localhost:8001"
assert get_order_total(BASE, 42) == 150000
assert get_order_total(BASE, 999) is None
print("consumer OK")
```

**Kiểm tra:** `python3 test_consumer.py` in ra `consumer OK`. Để ý dòng `.get("total")`: khi field biến mất, hàm không ném lỗi mà trả về `None`. Đó chính là kiểu hỏng âm thầm ở đầu bài, và rất nhiều code tích hợp được viết đúng như vậy.

Mock là một API bị cô lập, không bị ảnh hưởng khi service, database hay hệ thống bên ngoài thay đổi. Vì thế bài test này chạy được cả khi staging của khách đang sập.

Nhưng chính sự cô lập đó lại là điểm yếu. Nếu khách đổi `total` thành `amount`, mock vẫn trả `total` và test vẫn xanh. Mock chỉ giúp bạn khi khách chưa sẵn sàng. Nó không báo cho bạn biết khi khách đã đổi API.

**Điểm mấu chốt:** Mock giúp bạn làm việc khi khách chưa sẵn sàng; contract cho bạn biết khi khách đã đổi.

## Bước 4: Đem contract đi hỏi API thật

Đây là nửa còn lại, và cũng là lý do contract tồn tại. Pact gọi bước này là provider verification: từng request trong pact được gửi tới provider. Contract testing kiểm tra một điểm tích hợp bằng cách xét từng ứng dụng riêng rẽ. Bạn test phía mình trên mock, còn phía khách được test bằng chính file đó.

```python
# verify_provider.py — giản lược: so status và tên field, không so giá trị
import json, sys, urllib.request, urllib.error

contract = json.load(open("contract.json"))
base = sys.argv[1]
fails = 0
for it in contract["interactions"]:
req, exp = it["request"], it["response"]
try:
r = urllib.request.urlopen(base + req["path"])
status, body = r.status, json.load(r)
except urllib.error.HTTPError as e:
status, body = e.code, json.load(e)
missing = [k for k in exp["body"] if k not in body]
if status != exp["status"] or missing:
fails += 1
print(f"FAIL {it['description']}: status={status}, thiếu {missing}")
sys.exit(1 if fails else 0)
```

```bash
python3 verify_provider.py https://staging.khach-hang.example
```

Script cố ý chỉ so tên field chứ không so giá trị, vì đơn 42 trên staging của khách chắc chắn không có tổng tiền đúng bằng 150000. **Kiểm tra:** khi khách đổi tên field, bạn sẽ thấy `FAIL lấy đơn hàng tồn tại: status=200, thiếu ['total']` và exit code 1.

Contract test giúp debug nhanh hơn hẳn, và lý do nằm ngay trong dòng output đó: thông báo lỗi chỉ ra đúng field, đúng tương tác, thay vì một con số 0 bí ẩn trên dashboard.

Lưu ý một giả định ngầm: đơn 42 phải tồn tại trên staging. Hãy thống nhất với khách về dữ liệu test cố định, nếu không bước verify sẽ đỏ vì những lý do chẳng liên quan gì đến contract.

## Bước 5: Đưa vào CI để không phải nhớ chạy tay

Một script phải nhớ chạy tay thì sớm muộn cũng bị quên. Hãy cho `verify_provider.py` chạy theo lịch trong CI, và vì script trả exit code khác 0 khi fail, pipeline sẽ tự đỏ.

Với Pact, việc nối vào CI do Pact Broker đảm nhận. Khi số API và số đội phải verify bắt đầu tăng, đó là lúc nên bỏ bản tự viết và chuyển sang công cụ này.

## Ba lỗi hay gặp

Lỗi đầu tiên là mock quá tay. Nguyên tắc là tránh over-mocking và dùng API thật bất cứ khi nào có thể, nhất là với các luồng quan trọng. Nếu luồng thanh toán của khách chỉ từng chạy trên mock, bạn chưa thật sự test nó.

Lỗi thứ hai là để contract đứng yên. Test chỉ còn phản ánh đúng hệ thống khi được cập nhật theo API đang thay đổi. Khi khách thông báo đổi API, việc đầu tiên là sửa `contract.json` chứ không phải sửa code. Mock và bài verify sẽ tự theo sau.

Lỗi thứ ba là so khớp quá chặt. Nếu assert từng giá trị trên dữ liệu thật, test sẽ đỏ mỗi ngày và cả đội sẽ học cách bỏ qua nó.

## Làm tại chỗ khách, kỹ năng này dùng vào đâu?

Tuần đầu tại khách, API thường chưa xong, tài liệu thì cũ. Viết `contract.json` rồi mang đi hỏi kỹ sư bên khách là cách nhanh nhất để lộ ra những chỗ hai bên hiểu khác nhau, trước khi viết dòng code nào.

Đến lúc đi vào vận hành, bài verify chạy hằng đêm là thứ báo cho bạn biết khách vừa đổi API, thay vì phải chờ họ gọi điện báo.

Trong CV, đừng chỉ ghi "biết Pact". Hãy ghi bạn đã dựng contract cho bao nhiêu tương tác và đã chặn được loại thay đổi nào trước khi nó lên production.

Code tích hợp của bạn chỉ chạy đúng khi API của khách giữ nguyên như hai bên đã thỏa thuận. Contract test là cách để kiểm tra điều đó mỗi ngày.

**Thử ngay tuần này:**

- Chọn một API bên ngoài mà dự án của bạn đang gọi, viết contract.json cho 2 tương tác: một ca thành công và một ca lỗi.
- Chạy verify_provider.py với môi trường staging của API đó và cho vào CI để chạy hằng ngày.
- Thêm vào CV một dòng mô tả cụ thể: đã dựng contract test cho bao nhiêu tương tác, chặn được loại lỗi nào trước khi lên production.

## Nguồn

- [What is API mocking?](https://blog.postman.com/what-is-api-mocking/)

- [What is API Mocking? Definition, Guide, and Best Practices](https://katalon.com/resources-center/blog/what-is-api-mocking)

- [Introduction | Pact Docs](https://docs.pact.io/)

- [How Pact works | Pact Docs](https://docs.pact.io/getting_started/how_pact_works)

- [A Complete Guide to API Contract Testing](https://testsigma.com/blog/api-contract-testing/)

- [How to run API integration tests](https://www.merge.dev/blog/api-integration-testing)
