# Thực hành VCR.py và pytest: ghi API của khách một lần, chạy test offline mãi về sau

> Sandbox của khách có thể sập, token có thể hết hạn, nhưng bộ test tích hợp của bạn vẫn phải xanh trong CI mà không lộ một byte dữ liệu nào của khách.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-pytest-vcrpy-test-tich-hop-api-khach-khong-goi-that/

Thử hình dung: tuần thứ hai ở site khách, sandbox API của họ bắt đầu trả lỗi 503 vào mỗi buổi chiều. Lần nào cũng vậy, CI đỏ, dù code của bạn không đổi một dòng.

Cách xử lý gọn nhất là gọi API thật đúng một lần, ghi lại toàn bộ request và response, rồi để mọi lần chạy sau dùng bản ghi đó. VCR.py làm đúng việc này. Theo tài liệu của thư viện, lần chạy đầu tiên của test sẽ ghi request HTTP vào một file cassette dạng YAML, và từ đó trở đi test đọc lại từ file.

Bài này dựng một ví dụ hoàn chỉnh: client cho API đơn hàng giả định của khách, test pytest ghi một lần, dữ liệu nhạy cảm được che trước khi commit, và CI chạy hoàn toàn offline. Đây là thứ bạn sẽ cần ngay trong tuần đầu của một engagement tích hợp.

## Cần gì trước khi bắt đầu?

Bạn cần Python, pytest, thư viện `requests` và hai gói: `vcrpy` cùng `pytest-recording`. Gói thứ hai là plugin pytest chạy trên nền VCR.py, cho phép đánh dấu test bằng marker `pytest.mark.vcr` thay vì tự bọc từng đoạn code.

```bash
pip install pytest requests vcrpy pytest-recording
```

Endpoint `api.example-customer.com` dưới đây là tên giả định. Khi làm thật, bạn thay bằng sandbox của khách và một token họ cấp.

## Bước 1: viết client mỏng, tách khỏi test

Giữ mọi lời gọi HTTP trong một module riêng. Test sẽ gọi hàm này giống hệt cách code production gọi.

```python
# orders_client.py
import requests

BASE_URL = "https://api.example-customer.com"

def list_orders(token, status="open"):
    resp = requests.get(
        f"{BASE_URL}/v1/orders",
        params={"status": status},
        headers={"Authorization": f"Bearer {token}"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["orders"]
```

Cần kiểm tra: chạy thử hàm này một lần từ terminal với token thật để chắc rằng API trả về danh sách đơn hàng. Nếu bước này lỗi thì đừng ghi gì cả, vì cassette sẽ lưu luôn cái lỗi đó.

## Bước 2: ghi cassette đầu tiên bằng VCR.py thuần

Trước khi dùng plugin, hãy xem VCR.py hoạt động ra sao ở mức thấp nhất.

```python
# test_raw.py
import os
import vcr
from orders_client import list_orders

def test_list_orders_raw():
    with vcr.use_cassette("fixtures/vcr_cassettes/orders.yaml"):
        orders = list_orders(token=os.environ["CUSTOMER_TOKEN"])
    assert isinstance(orders, list)
```

Chế độ ghi mặc định của VCR.py là `once`: nếu file cassette chưa tồn tại thì nó ghi tương tác mới, còn nếu đã có thì chỉ phát lại. Chạy test hai lần, lần thứ hai sẽ nhanh hơn hẳn vì không còn đi qua mạng.

Bây giờ hãy mở `orders.yaml`. Bạn sẽ thấy header `Authorization` cùng token nằm nguyên văn trong file, kèm toàn bộ nội dung đơn hàng của khách. Ghi lại vài địa chỉ email thật và tên miền email bạn thấy ở đây, lát nữa sẽ dùng để kiểm tra. Đừng commit file này. Xoá nó đi, rồi chuyển sang bước 3.

## Bước 3: chuyển sang pytest-recording và lọc bí mật

Với pytest-recording, cấu hình ghi nằm trong fixture `vcr_config`. Đây cũng là chỗ khai báo những gì không được phép lọt vào cassette.

```python
# conftest.py
import pytest
from scrub import scrub_response

@pytest.fixture(scope="module")
def vcr_config():
    return {
        "filter_headers": ["authorization"],
        "filter_query_parameters": ["api_key"],
        "before_record_response": scrub_response,
    }
```

`filter_headers` nhận một danh sách header cần bỏ khỏi bản ghi, còn `filter_query_parameters` làm việc tương tự với query string. Tham số thứ hai cần có khi khách truyền khoá qua URL kiểu `?api_key=...`, chuyện vẫn gặp ở các hệ thống cũ.

```python
# test_orders.py
import os
import pytest
from orders_client import list_orders

@pytest.mark.vcr
def test_list_open_orders():
    token = os.environ.get("CUSTOMER_TOKEN", "dummy")
    orders = list_orders(token=token)
    assert all(o["status"] == "open" for o in orders)
```

Lưu ý giá trị `"dummy"`: khi phát lại, test không cần token thật. Trên CI không có secret nào, và đó chính là điều bạn muốn.

## Bước 4: che dữ liệu của khách trong response

Lọc header mới xử lý được nửa vấn đề. Phần body response vẫn chứa email, tên, địa chỉ của khách hàng cuối. Hook `before_record_response` cho phép sửa response trước khi ghi, hoặc trả về `None` để bỏ hẳn cả cặp request và response đó khỏi cassette.

```python
# scrub.py
import json

def scrub_response(response):
    body = response["body"]["string"]
    try:
        data = json.loads(body)
    except ValueError:
        return response
    for order in data.get("orders", []):
        order["customer_email"] = "redacted@example.com"
    response["body"]["string"] = json.dumps(data).encode()
    return response
```

Đoạn này đã được đơn giản hoá. Nó giả định body là JSON chưa nén và trường cần che tên là `customer_email`. Với API thật, hãy hỏi khách trường nào là dữ liệu cá nhân thay vì tự đoán.

Giờ ghi lại. pytest-recording mặc định chạy ở chế độ `none` để không gọi mạng ngoài ý muốn, nên bạn phải chủ động bật chế độ ghi. Vì cấu hình ghi của plugin nằm trong `vcr_config`, cách dễ theo dõi nhất là tạm đặt chế độ `once` ngay trong fixture đó trên máy bạn, chạy test một lần với token thật, rồi bỏ dòng ấy đi trước khi commit.

```bash
CUSTOMER_TOKEN=xxx pytest test_orders.py
```

Cần kiểm tra: mở file cassette vừa sinh ra, tìm chữ `Bearer`, tìm token, rồi tìm tên miền email thật của khách và những địa chỉ email bạn đã ghi lại ở bước 2. Đừng tìm ký tự `@`, vì chính giá trị thay thế `redacted@example.com` cũng chứa nó. Không còn kết quả nào thì mới được commit.

## Bước 5: khoá CI vào chế độ offline

Trong CI, bạn không cần cấu hình gì thêm cho chế độ ghi, vì `none` đã là mặc định. Chế độ này báo lỗi với mọi request mới, nên nếu ai đó thêm một lời gọi API mà quên ghi cassette, test sẽ đỏ ngay chứ không lặng lẽ gọi ra ngoài.

```bash
pytest --block-network
```

Cờ `--block-network` chặn toàn bộ truy cập mạng trong lúc chạy test, kể cả những test không gắn marker `vcr`. Nó là lớp bảo vệ thứ hai, bắt được các lời gọi mạng nằm ngoài đường đi của VCR.

**Điểm mấu chốt:** Test tích hợp tốt nhất là test chạy được khi hệ thống của khách đang sập.

## Khi API của khách đổi, ghi lại thế nào?

Đây là lúc nhiều người làm hỏng cassette. Mỗi chế độ ghi phục vụ một tình huống khác nhau, và chọn sai có thể khiến CI vô tình gọi mạng thật.

| Chế độ | Hành vi | Dùng khi |
|---|---|---|
| `once` | Chỉ ghi khi chưa có cassette | Ghi lần đầu trên máy bạn |
| `new_episodes` | Phát lại cái cũ, ghi thêm tương tác mới | Thêm một endpoint vào test có sẵn |
| `none` | Báo lỗi với mọi request mới | CI và mọi lần chạy thường ngày |
| `all` | Không bao giờ phát lại, luôn ghi | Hiếm khi; gần như không bao giờ trong CI |
| `rewrite` | Ghi lại cassette từ đầu (pytest-recording) | Khách đổi schema, cần bản ghi sạch |

Khi khách báo đã đổi schema, hãy chạy `rewrite` trên máy cá nhân, mở file kiểm tra bí mật như ở bước 4, rồi commit trong một pull request riêng. Diff của cassette lúc đó cho bạn thấy chính xác API đã thay đổi những gì.

## Những lỗi hay gặp

Lỗi đầu tiên liên quan đến cách khớp request. Mặc định VCR.py khớp theo method, scheme, host, port, path và query, tức là không xét body. Hai lời gọi `POST /v1/orders` với body khác nhau có thể bị coi là một, và test của bạn nhận nhầm response.

Khi cần test nhiều POST trên cùng URL, cách an toàn là cho mỗi lời gọi một file cassette riêng bằng `vcr.use_cassette` như ở bước 2.

Lỗi thứ hai là thêm filter sau khi đã commit cassette. Filter chỉ có tác dụng với lần ghi tiếp theo, không xoá được token đã nằm trong lịch sử git. Nếu chuyện đó xảy ra, hãy báo khách để họ thu hồi token, đừng chỉ sửa file.

Lỗi thứ ba là quên bỏ chế độ ghi khỏi `vcr_config` sau một buổi debug, tệ nhất là để `all`. Mọi lần chạy sẽ gọi API thật, CI bắt đầu cần token, và bạn quay về đúng chỗ xuất phát.

## Kỹ năng này dùng thế nào ở site khách?

Ở một engagement thật, cassette còn là tài liệu. Khi khách hỏi hệ thống của bạn gửi gì sang API của họ, bạn mở file YAML cho họ xem request đã được che dữ liệu. Câu trả lời đó thuyết phục hơn mọi sơ đồ kiến trúc.

Nó cũng giúp bàn giao. Kỹ sư phía khách clone repo, chạy `pytest --block-network` và thấy test xanh mà không cần xin quyền truy cập sandbox. Bạn rời site, bộ test vẫn ở lại và vẫn chạy được.

Về nghề nghiệp: khi đọc JD cho vị trí FDE, hãy để ý những cụm như "integrate with customer systems" hay "own integrations end to end". Trong CV, thay vì ghi "viết test", hãy mô tả kết quả, ví dụ "dựng bộ test tích hợp offline cho API của khách, ghi bằng VCR.py, lọc credential và dữ liệu cá nhân, CI không cần mạng".

Khi đi phỏng vấn, nếu bạn nói rõ được vì sao CI nên chạy ở chế độ `none`, người đối diện sẽ thấy bạn hiểu rủi ro, chứ không chỉ biết dùng một thư viện.

Lần tới sandbox của khách trả 503, CI của bạn sẽ không bị ảnh hưởng. Bạn chỉ cần gọi lại API thật vào hôm khách thông báo đổi schema.

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

- Chọn một hàm gọi API bên ngoài trong dự án hiện tại, viết một test có @pytest.mark.vcr, ghi cassette rồi chạy lại với --block-network.
- Mở một file cassette bạn đã ghi, tìm chữ Authorization, token và tên miền email thật của khách; thêm filter cho đến khi không còn cái nào.
- Thêm bước pytest --block-network vào CI để biết test nào vẫn đang âm thầm gọi mạng.

## Nguồn

- [VCR.py Usage](https://vcrpy.readthedocs.io/en/latest/usage.html)

- [VCR.py Configuration](https://vcrpy.readthedocs.io/en/latest/configuration.html)

- [VCR.py Advanced Features](https://vcrpy.readthedocs.io/en/latest/advanced.html)

- [pytest-recording · PyPI](https://pypi.org/project/pytest-recording/)

- [kiwicom/pytest-recording (GitHub)](https://github.com/kiwicom/pytest-recording)
