Skip to content

第 15 章 · pytest 集成测试

本章目标:掌握 pytest-httpx 插件和 pytest-asyncio,写出可复现、可并行的 HTTP 客户端测试。

15.1 为什么 HTTP 测试需要专门工具

直接发真实 HTTP 请求做测试有三个问题:

  1. 速度慢:每次测试都要等待网络 I/O;
  2. 不稳定:依赖外部服务的可用性,容易 flaky;
  3. 有副作用:可能产生真实数据(如创建用户、扣款)。

解决方案是拦截并 mock 网络层。pytest-httpx 和 httpx 自带的 MockTransport 都能做到这一点。

15.2 pytest-httpx 插件:匹配即返回

pytest-httpx 是最流行的 pytest 插件,通过 fixture 提供请求拦截:

bash
pip install pytest-httpx httpx
python
# tests/test_api_client.py
import pytest
import httpx
from myapp.client import GitHubClient


def test_get_user(consumer: httpx.Client) -> None:
    """用 fixture 拦截请求,返回预设响应"""
    # 注册期望的请求和响应
    consumer.register_pattern(
        httpx.MatchRequest("GET", "https://api.github.com/users/octocat"),
        httpx.Response(200, json={"login": "octocat", "stars": 1000}),
    )
    
    client = GitHubClient()
    user = client.get_user("octocat")
    
    assert user["login"] == "octocat"
    assert user["stars"] == 1000

match 粒度控制

MatchRequest 支持精确匹配 URL、方法、headers、body,适合需要区分不同请求场景的测试。

15.3 简写 fixture:clientasync_client

pytest-httpx 提供两个便捷 fixture:

python
import pytest
import httpx


def test_simple_get(client: httpx.Client) -> None:
    """client fixture 自动注册 pattern"""
    client.register(
        httpx.Response(200, json={"ok": True}),
        match=[httpx.MatchRequest(url="https://httpbin.org/get")]
    )
    resp = client.get("https://httpbin.org/get")
    assert resp.json() == {"ok": True}


@pytest.mark.asyncio
async def test_async_get(async_client: httpx.AsyncClient) -> None:
    """async_client fixture 用于异步测试"""
    async_client.register(
        httpx.Response(200, json={"async": True}),
        match=[httpx.MatchRequest(url="https://httpbin.org/get")]
    )
    resp = await async_client.get("https://httpbin.org/get")
    assert resp.json() == {"async": True}

15.4 使用 MockTransport:纯 httpx 方案

不依赖第三方插件时,httpx 内置 MockTransport

python
import httpx


def mock_handler(request: httpx.Request) -> httpx.Response:
    if "/users/" in str(request.url):
        return httpx.Response(200, json={"id": 1, "name": "Alice"})
    return httpx.Response(404)


def test_with_mock_transport() -> None:
    with httpx.Client(transport=httpx.MockTransport(mock_handler)) as client:
        resp = client.get("https://api.example.com/users/1")
        assert resp.status_code == 200
        assert resp.json()["name"] == "Alice"
        
        resp = client.get("https://api.example.com/missing")
        assert resp.status_code == 404

MockTransport 适合不需要 pytest fixture 的简单场景,或在非 pytest 测试框架中使用。

15.5 测试异步客户端

结合 pytest-asyncio 测试异步代码:

bash
pip install pytest-asyncio
python
# conftest.py 中配置 asyncio 模式
import pytest

@pytest.fixture(scope="session")
def asyncio_mode():
    return "auto"  # 自动识别 async def 测试
python
import pytest
import httpx
import pytest_asyncio


@pytest.mark.asyncio
async def test_async_flow(async_client: httpx.AsyncClient) -> None:
    async_client.register(
        httpx.Response(200, json={"token": "abc123"}),
        match=[httpx.MatchRequest(url="https://auth.example.com/token")]
    )
    
    async with async_client as client:
        resp = await client.post(
            "https://auth.example.com/token",
            json={"username": "user", "password": "pass"}
        )
        assert resp.json()["token"] == "abc123"

15.6 完整测试示例:API 客户端

python
# myapp/client.py
import httpx

class APIClient:
    def __init__(self, base_url: str, api_key: str):
        self.base_url = base_url
        self.api_key = api_key
    
    def get_user(self, user_id: int) -> dict:
        resp = httpx.get(
            f"{self.base_url}/users/{user_id}",
            headers={"Authorization": f"Bearer {self.api_key}"}
        )
        resp.raise_for_status()
        return resp.json()


# tests/test_client.py
import pytest
import httpx


def test_get_user_success(client: httpx.Client) -> None:
    client.register(
        httpx.Response(200, json={"id": 42, "name": "Bob"}),
        match=[
            httpx.MatchRequest(
                url="https://api.example.com/users/42",
                headers={"Authorization": "Bearer test-key"}
            )
        ]
    )
    
    from myapp.client import APIClient
    api = APIClient("https://api.example.com", "test-key")
    user = api.get_user(42)
    
    assert user["name"] == "Bob"
    assert user["id"] == 42


def test_get_user_not_found(client: httpx.Client) -> None:
    client.register(
        httpx.Response(404, json={"error": "not found"}),
        match=[httpx.MatchRequest(url="https://api.example.com/users/999")]
    )
    
    from myapp.client import APIClient
    api = APIClient("https://api.example.com", "test-key")
    
    with pytest.raises(httpx.HTTPStatusError):
        api.get_user(999)

15.7 本章小结

  • pytest-httpx 插件提供 client/async_client fixture 和 register/register_pattern 方法;
  • MockTransport 是 httpx 内置方案,无需额外依赖;
  • 结合 pytest-asyncio 可测试异步客户端;
  • 测试应覆盖成功、客户端错误(4xx)、服务端错误(5xx)三种路径。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. pytest-httpx 的 client fixture 默认是否自动发送真实请求?

2. httpx.MockTransport 的处理器函数签名是什么?

3. pytest-asyncio 中测试 async def 函数的正确装饰器是?

4. register_pattern 与 register 的主要区别是?

🛠️ 动手实践

  1. 用 pytest-httpx 测试一个封装了 GET/POST 的简单 REST 客户端,覆盖成功和 404 两种情况。
  2. 用 MockTransport 实现一个不依赖 pytest-httpx 的异步 HTTP 测试。
  3. 编写一个测试,验证客户端在 503 服务不可用时抛出正确异常并包含错误信息。

完成练习后,进入下一章:性能优化与资源管理