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

幾個亮點:

  1. JSON schema 驗 response — 比一個個 assert 欄位完整
  2. parametrize — 6 個 invalid email 一行寫完
  3. 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 自動跑

反模式

  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 直接少一半