API 測試實戰 — pytest + requests + schema 驗證的完整套餐
API 測試不是用 Postman 點來點去。寫成 code 才能進 CI、才能跑回歸、才能跟 PR 連動。這篇給你一份 production-ready 的 pytest API 測試骨架。
為什麼用 pytest 而非 Postman
| Postman | pytest | |
|---|---|---|
| 學習曲線 | 低 | 中 |
| 易讀性 | 高(GUI) | 中(code) |
| CI 整合 | Newman 還行 | 原生 |
| 版本管理 | JSON 檔不好 diff | Python diff 友好 |
| 共用邏輯 | 有限 | fixtures / classes |
| 跨服務串接 | 弱 | 強 |
| Schema 驗證 | 有但弱 | 強(jsonschema) |
| Data-driven test | 弱 | parametrize |
結論:Postman 適合手動探索、pytest 適合自動化。兩個一起用。
起手套件
pip install pytest requests jsonschema pytest-xdist pytest-html
| 套件 | 用途 |
|---|---|
| pytest | test runner |
| requests | HTTP client |
| jsonschema | 驗 response schema |
| pytest-xdist | 平行 |
| pytest-html | HTML 報告 |
專案結構
api-tests/
├── conftest.py # 共用 fixtures
├── pytest.ini # 設定
├── requirements.txt
├── schemas/ # JSON Schema
│ ├── user.json
│ └── order.json
├── tests/
│ ├── test_auth.py
│ ├── test_users.py
│ └── test_orders.py
└── utils/
├── client.py # 封裝 API client
└── factory.py # test data factory
conftest.py — 共用設定
# conftest.py
import os
import pytest
import requests
from utils.client import APIClient
@pytest.fixture(scope='session')
def base_url():
return os.getenv('API_BASE_URL', 'https://api-staging.example.com')
@pytest.fixture(scope='session')
def api_token(base_url):
"""以 service account 取得 token、整 session 重用"""
resp = requests.post(f'{base_url}/auth/login', json={
'email': os.environ['TEST_USER'],
'password': os.environ['TEST_PASS'],
})
resp.raise_for_status()
return resp.json()['access_token']
@pytest.fixture
def api(base_url, api_token):
"""每個 test 拿 client(裡面有 token、有 retry)"""
return APIClient(base_url=base_url, token=api_token)
@pytest.fixture
def fresh_user(api):
"""每個 test 一個新建使用者,跑完自動清"""
user = api.post('/users', json={
'email': f'test-{pytest.uid()}@example.com',
'name': 'Test User',
}).json()
yield user
api.delete(f'/users/{user["id"]}')
utils/client.py — 封裝 API client
# utils/client.py
import requests
from urllib.parse import urljoin
class APIClient:
def __init__(self, base_url: str, token: str = None, timeout: int = 10):
self.base_url = base_url
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'qa-pytest/1.0',
})
if token:
self.session.headers['Authorization'] = f'Bearer {token}'
def request(self, method, path, **kwargs):
url = urljoin(self.base_url, path)
kwargs.setdefault('timeout', self.timeout)
resp = self.session.request(method, url, **kwargs)
# 印出方便 debug(pytest 失敗時會顯示)
print(f'{method} {url} → {resp.status_code} ({resp.elapsed.total_seconds():.2f}s)')
return resp
def get(self, path, **kw): return self.request('GET', path, **kw)
def post(self, path, **kw): return self.request('POST', path, **kw)
def put(self, path, **kw): return self.request('PUT', path, **kw)
def patch(self, path, **kw): return self.request('PATCH', path, **kw)
def delete(self, path, **kw): return self.request('DELETE', path, **kw)
重點:用 requests.Session() — 一個 session 共用 connection pool、cookies、headers,比每次 new Client 快。
tests/test_users.py — 第一個 test
# tests/test_users.py
import pytest
from jsonschema import validate
USER_SCHEMA = {
"type": "object",
"required": ["id", "email", "name", "created_at"],
"properties": {
"id": {"type": "integer"},
"email": {"type": "string", "format": "email"},
"name": {"type": "string"},
"created_at": {"type": "string", "format": "date-time"},
"role": {"type": "string", "enum": ["user", "admin"]},
}
}
class TestUserAPI:
def test_create_user(self, api):
resp = api.post('/users', json={
'email': '[email protected]',
'name': 'Alice',
})
assert resp.status_code == 201
validate(resp.json(), USER_SCHEMA)
assert resp.json()['email'] == '[email protected]'
def test_get_user(self, api, fresh_user):
resp = api.get(f'/users/{fresh_user["id"]}')
assert resp.status_code == 200
assert resp.json()['id'] == fresh_user['id']
def test_get_nonexistent_user(self, api):
resp = api.get('/users/99999999')
assert resp.status_code == 404
assert resp.json()['error'] == 'USER_NOT_FOUND'
@pytest.mark.parametrize('invalid_email', [
'',
'not-an-email',
'a@',
'@b.com',
'a@b',
'a' * 256 + '@example.com', # 超長
])
def test_create_user_invalid_email(self, api, invalid_email):
resp = api.post('/users', json={
'email': invalid_email,
'name': 'Alice',
})
assert resp.status_code == 422
assert 'email' in resp.json()['errors']
幾個亮點:
- JSON schema 驗 response — 比一個個 assert 欄位完整
- parametrize — 6 個 invalid email 一行寫完
- fresh_user fixture — 自動 setup + cleanup
JSON Schema 驗證進階
把 schema 放檔案:
// schemas/user.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "email"],
"properties": {
"id": {"type": "integer", "minimum": 1},
"email": {"type": "string", "format": "email"}
}
}
讀進來:
# utils/schema.py
import json
from pathlib import Path
from jsonschema import validate
SCHEMA_DIR = Path(__file__).parent.parent / 'schemas'
def load_schema(name):
return json.loads((SCHEMA_DIR / f'{name}.json').read_text())
def validate_schema(data, name):
validate(data, load_schema(name))
使用:
from utils.schema import validate_schema
def test_user_schema(api, fresh_user):
resp = api.get(f'/users/{fresh_user["id"]}')
validate_schema(resp.json(), 'user')
Schema 來源建議:從你後端 OpenAPI / Swagger 自動轉。手寫 schema 會跟實作脫鉤。
Auth 測試 pattern
1. Token 過期
def test_expired_token(base_url):
# 取一個故意過期的 token (or sleep)
expired_token = 'eyJhbGciOiJIUzI1NiI...' # 過期 token
resp = requests.get(f'{base_url}/users/me', headers={
'Authorization': f'Bearer {expired_token}'
})
assert resp.status_code == 401
assert resp.json()['error'] == 'TOKEN_EXPIRED'
2. 無 token
def test_no_token(base_url):
resp = requests.get(f'{base_url}/users/me')
assert resp.status_code == 401
3. 權限不夠(403)
def test_user_cannot_access_admin(api):
# api 是普通使用者
resp = api.get('/admin/users')
assert resp.status_code == 403
4. 跨使用者讀取
def test_cannot_read_other_user(api):
other_user_id = 9999
resp = api.get(f'/users/{other_user_id}/private-data')
assert resp.status_code in (403, 404) # 看設計
Schema validation 進階:response 結構保證
ORDER_SCHEMA = {
"type": "object",
"required": ["id", "items", "total"],
"properties": {
"id": {"type": "integer"},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty", "price"],
"properties": {
"sku": {"type": "string", "pattern": "^[A-Z]{3}-\\d{3}$"},
"qty": {"type": "integer", "minimum": 1},
"price": {"type": "number", "minimum": 0},
}
}
},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["TWD", "USD"]},
},
"additionalProperties": False # 嚴格模式:多欄位也 fail
}
additionalProperties: false 抓回傳多欄位的 regression(例如不該洩漏 internal_notes)。
Data-driven tests
parametrize 多組
@pytest.mark.parametrize('payload, expected_status, expected_error', [
({'email': '[email protected]', 'name': 'A'}, 201, None),
({'email': '', 'name': 'A'}, 422, 'EMAIL_REQUIRED'),
({'email': '[email protected]', 'name': ''}, 422, 'NAME_REQUIRED'),
({'email': '[email protected]', 'name': 'X' * 101}, 422, 'NAME_TOO_LONG'),
])
def test_create_user_validation(api, payload, expected_status, expected_error):
resp = api.post('/users', json=payload)
assert resp.status_code == expected_status
if expected_error:
assert resp.json()['error'] == expected_error
一個 test 寫法、4 個 case 跑。
YAML / JSON 驅動
import yaml
from pathlib import Path
CASES = yaml.safe_load((Path(__file__).parent / 'cases/login.yaml').read_text())
@pytest.mark.parametrize('case', CASES, ids=lambda c: c['name'])
def test_login(api, case):
resp = api.post('/auth/login', json=case['payload'])
assert resp.status_code == case['expected']['status']
非工程師也能維護 cases。
平行跑
# 4 worker 平行
pytest -n 4
# auto 偵測 CPU 數
pytest -n auto
注意:有狀態的 test 不能平行。要 mark:
@pytest.mark.serial
def test_global_state(api): ...
# 分兩階段跑
pytest -n 4 -m "not serial"
pytest -m serial
CI 整合(GitHub Actions)
name: API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- name: Run tests
run: pytest -n auto --html=report.html --self-contained-html --junitxml=results.xml
env:
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
- uses: dorny/test-reporter@v1
if: always()
with:
name: API Tests
path: results.xml
reporter: java-junit
- uses: actions/upload-artifact@v4
if: always()
with:
name: html-report
path: report.html
Postman 互補的使用法
Postman 適合: - 探索新 API(看 response 結構、抓 schema) - 一次性 debug - 給非工程師用
pytest 適合: - CI 自動化 - 大量 case - 跨服務整合
工作流:
1. PM 給 API spec
2. Postman 開來打、看 response、確認 happy path
3. 想出 10 個 case → 寫進 pytest
4. CI 自動跑
反模式
- 每個 test 自己 login → 浪費時間、token 灌爆 auth service → 用 session scope fixture
- assert 只看 status → 200 但 response 壞了你不知道 → schema 驗
- hard-code env URL → CI 換環境就壞 → 用 env var
- test 互相依賴 → 一個 fail 全 fail → fixture 隔離
- 沒 cleanup → 跑久了 staging DB 一堆垃圾 → fixture teardown
- schema 用
additionalProperties: true→ 後端多吐欄位也不知 → 改 false - 打 prod 而非 staging → 真實使用者看到測試資料 → CI 鎖 staging URL
進階主題(之後可以深入)
- Contract testing with Pact — 確保前後端 API 不打架
- Performance test with Locust — 把 API test 當壓測腳本
- Chaos / fault injection — 故意讓第三方 timeout
- OpenAPI 自動生 schema — 對 spec 一致
- Mock server with WireMock — 後端沒好就先測前端
最後
API 測試是 QA 性價比最高的投資 — 跑快、抓多、不易 flaky。先把 happy path + error path + schema 三件事做好,就贏過 80% 團隊。Postman + pytest 兩段式工作流、進 CI、跟 PR 串接,你會發現上線 bug 直接少一半。