Chapter 08

测试与质量保证

使用 pytest 构建完整测试套件:TestClient 同步测试、AsyncClient 异步测试、Fixture 数据库隔离、dependency_overrides 模拟依赖。

安装测试依赖

pip install pytest pytest-asyncio httpx pytest-cov

# pyproject.toml 配置
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
# testpaths = ["tests"]

TestClient 同步测试

TestClient 封装了 HTTPX,提供类 requests 的同步接口,适合大多数路由测试场景:

# tests/test_basic.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

# ── 基本 GET 测试 ─────────────────────────────────────────
def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "ok"

# ── POST 测试(请求体)────────────────────────────────────
def test_create_user():
    response = client.post("/users/", json={
        "username": "testuser",
        "email": "test@example.com",
        "password": "secret123"
    })
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "testuser"
    assert "password" not in data  # 确认密码未泄露
    assert "id" in data

# ── 验证错误测试 ──────────────────────────────────────────
def test_create_user_invalid_data():
    response = client.post("/users/", json={
        "username": "",  # 空用户名应验证失败
        "email": "not-an-email"
    })
    assert response.status_code == 422  # Unprocessable Entity
    errors = response.json()["detail"]
    assert len(errors) > 0

# ── 404 测试 ─────────────────────────────────────────────
def test_get_nonexistent_user():
    response = client.get("/users/99999")
    assert response.status_code == 404
    assert "不存在" in response.json()["detail"]

Fixture:数据库测试隔离

# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.main import app
from app.database import Base, get_db

# 使用独立的内存数据库进行测试
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"

@pytest.fixture(scope="session")
def event_loop_policy():
    import asyncio
    return asyncio.DefaultEventLoopPolicy()

@pytest_asyncio.fixture(scope="function")
async def test_db():
    """每个测试函数使用独立的内存数据库,测试完毕自动清空"""
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    session_factory = async_sessionmaker(engine, expire_on_commit=False)

    async with session_factory() as session:
        yield session  # 注入测试使用的 Session

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()

@pytest_asyncio.fixture()
async def async_client(test_db: AsyncSession):
    """覆盖数据库依赖,使用测试数据库"""
    async def override_get_db():
        yield test_db

    # dependency_overrides:替换真实依赖为测试版本
    app.dependency_overrides[get_db] = override_get_db

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as client:
        yield client

    app.dependency_overrides.clear()  # 清除覆盖,避免影响其他测试

AsyncClient 异步测试

# tests/test_users.py
import pytest
from httpx import AsyncClient

@pytest.mark.asyncio
async def test_create_and_get_user(async_client: AsyncClient):
    # 创建用户
    create_resp = await async_client.post("/users/", json={
        "username": "alice",
        "email": "alice@example.com",
        "password": "password123"
    })
    assert create_resp.status_code == 201
    user_id = create_resp.json()["id"]

    # 获取用户
    get_resp = await async_client.get(f"/users/{user_id}")
    assert get_resp.status_code == 200
    user = get_resp.json()
    assert user["username"] == "alice"
    assert "hashed_password" not in user

@pytest.mark.asyncio
async def test_login_and_access_protected(async_client: AsyncClient):
    # 先注册
    await async_client.post("/users/", json={
        "username": "bob",
        "email": "bob@example.com",
        "password": "mypassword"
    })

    # 登录获取 Token
    login_resp = await async_client.post(
        "/auth/login",
        data={"username": "bob", "password": "mypassword"}
    )
    assert login_resp.status_code == 200
    token = login_resp.json()["access_token"]

    # 用 Token 访问受保护端点
    me_resp = await async_client.get(
        "/auth/me",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert me_resp.status_code == 200

dependency_overrides 模拟依赖

# 模拟认证依赖(测试受保护端点时无需真实 Token)
def mock_current_user():
    return {"id": 1, "username": "testuser", "role": "admin"}

@pytest.fixture
def authenticated_client():
    from app.routers.auth import get_current_user
    app.dependency_overrides[get_current_user] = mock_current_user
    with TestClient(app) as client:
        yield client
    app.dependency_overrides.clear()

def test_admin_endpoint(authenticated_client):
    # 不需要真实 JWT,依赖被替换为返回 mock 用户的函数
    response = authenticated_client.get("/admin/stats")
    assert response.status_code == 200

运行测试与覆盖率

# 运行所有测试
pytest

# 显示详细输出
pytest -v

# 生成覆盖率报告
pytest --cov=app --cov-report=html --cov-report=term-missing

# 仅运行某个测试文件
pytest tests/test_users.py -v

# 运行带特定标记的测试
pytest -m asyncio

# 覆盖率报告查看(HTML 版本)
# 打开 htmlcov/index.html
测试质量目标 核心业务逻辑(用户注册、登录、CRUD)覆盖率目标 90%+。每个路由至少有:正常情况测试、边界条件测试(空值、超长值)、错误情况测试(404、403、422)。使用 dependency_overrides 避免测试依赖外部服务(数据库、第三方 API)。
本章小结 FastAPI 测试工具箱:TestClient(同步,简单)、AsyncClient(异步,完整)、conftest.py 管理 Fixture、dependency_overrides 隔离外部依赖。测试数据库使用内存 SQLite,每个测试函数独立数据库,互不干扰。下一章学习生产部署:Docker、Gunicorn、Redis 缓存与 Prometheus 监控。