---
title: API 測試實戰 — pytest + requests + schema 驗證的完整套餐
description: 從零搭起 API 測試框架。pytest fixtures、requests session、JSON schema 驗證、auth 處理、CI 整合，附完整範例。
category: automation
tags: [api-testing, pytest, requests, jsonschema, python]
date: 2026-06-10
---

# 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 適合自動化。**兩個一起用**。

## 起手套件

```bash
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 — 共用設定

```python
# 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

```python
# 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

```python
# 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': 'new@example.com',
            'name': 'Alice',
        })
        assert resp.status_code == 201
        validate(resp.json(), USER_SCHEMA)
        assert resp.json()['email'] == 'new@example.com'

    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']
```

幾個亮點：

1. **JSON schema 驗 response** — 比一個個 assert 欄位完整
2. **parametrize** — 6 個 invalid email 一行寫完
3. **fresh_user fixture** — 自動 setup + cleanup

## JSON Schema 驗證進階

把 schema 放檔案：

```json
// 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"}
  }
}
```

讀進來：

```python
# 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))
```

使用：

```python
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 過期

```python
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

```python
def test_no_token(base_url):
    resp = requests.get(f'{base_url}/users/me')
    assert resp.status_code == 401
```

### 3. 權限不夠（403）

```python
def test_user_cannot_access_admin(api):
    # api 是普通使用者
    resp = api.get('/admin/users')
    assert resp.status_code == 403
```

### 4. 跨使用者讀取

```python
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 結構保證

```python
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 多組

```python
@pytest.mark.parametrize('payload, expected_status, expected_error', [
    ({'email': 'valid@x.com', 'name': 'A'}, 201, None),
    ({'email': '', 'name': 'A'}, 422, 'EMAIL_REQUIRED'),
    ({'email': 'a@b.com', 'name': ''}, 422, 'NAME_REQUIRED'),
    ({'email': 'a@b.com', '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 驅動

```python
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。

## 平行跑

```bash
# 4 worker 平行
pytest -n 4

# auto 偵測 CPU 數
pytest -n auto
```

注意：**有狀態的 test 不能平行**。要 mark：

```python
@pytest.mark.serial
def test_global_state(api): ...
```

```bash
# 分兩階段跑
pytest -n 4 -m "not serial"
pytest -m serial
```

## CI 整合（GitHub Actions）

```yaml
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 自動跑
```

## 反模式

1. **每個 test 自己 login** → 浪費時間、token 灌爆 auth service → 用 session scope fixture
2. **assert 只看 status** → 200 但 response 壞了你不知道 → schema 驗
3. **hard-code env URL** → CI 換環境就壞 → 用 env var
4. **test 互相依賴** → 一個 fail 全 fail → fixture 隔離
5. **沒 cleanup** → 跑久了 staging DB 一堆垃圾 → fixture teardown
6. **schema 用 `additionalProperties: true`** → 後端多吐欄位也不知 → 改 false
7. **打 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 直接少一半**。
