K 的一隅

Python FastAPI 后端实战

接口也能测:pytest 与 TestClient

用 pytest 组织用例、TestClient 发 HTTP 请求、断言状态码与 JSON,以及 dependency_overrides 与 fixture 的常见写法。

9 分钟阅读 更新于 2026-07-28
本文目录

手动 curl 验证接口,发版前跑一遍还能接受;用例一多、字段一改,人就变成瓶颈。自动化测试把「给定输入 → 期望 HTTP 状态与 Body」写成可重复执行的代码。FastAPI 基于 Starlette 的 TestClient 在进程内调用 ASGI 应用,不真正起 TCP 端口,却走完整路由、依赖、中间件(与生产路径一致)。配合 pytest 的 fixture 与参数化,接口测试可以和普通 Python 单测放在同一套流水线里。

TestClient:进程内 HTTP

TestClient(app) 提供 .get.post 等方法,返回 Response,属性包括 status_code.json().headers

python
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


client = TestClient(app)


def test_health() -> None:
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}

TestClient 在同步测试函数里使用最顺手;内部用 httpx 驱动 ASGI。若测试本身是 async,可用 httpx.AsyncClient + ASGITransport(app=app),与 TestClient 等价,这里以同步 pytest 为主流写法。

raise_server_exceptions=False 时,未捕获异常会转成 500 Response 而不是把 traceback 抛进测试进程——测错误 envelope 时常用。默认 True 更适合「异常即失败」的快速定位。follow_redirects=True 测 307 登录跳转链。

pytest 项目布局

测试目录常见两种:tests/ 与源码平级,或 myapp/tests/conftest.py 里放共享 fixture,pytest 自动发现。按层分子目录更清晰:

tests/
  conftest.py          # client, db_session, app
  api/
    test_orders.py     # TestClient
  services/
    test_order_service.py
  repositories/
    test_order_repo.py

pyproject.toml 里配置 testpaths = ["tests"],CI 跑 pytest -q。与生产依赖分离:requirements-dev.txt 或 optional [dev] extra 装 pytest、httpx。

pytest 组织:fixture 复用 app 与 client

每个测试都 TestClient(app) 可以,但更好把 app 工厂client 抽成 fixture,并在 teardown 里清理 dependency_overrides

python
import pytest
from collections.abc import Generator
from fastapi.testclient import TestClient

from myapp.main import app, get_db


@pytest.fixture
def client() -> Generator[TestClient, None, None]:
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()


@pytest.fixture
def client_with_fake_db(db_session: Session) -> Generator[TestClient, None, None]:
    def override_get_db() -> Generator[Session, None, None]:
        yield db_session

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

with TestClient(app) 会在退出时触发 lifespan(若应用配置了 lifespan),更接近真实启动/关闭。测试结束 clear() overrides 避免用例间污染。

create_app() 工厂模式便于测试注入不同 settings:

python
@pytest.fixture
def app() -> FastAPI:
    return create_app(settings=Settings(database_url="sqlite:///:memory:"))


@pytest.fixture
def client(app: FastAPI) -> Generator[TestClient, None, None]:
    with TestClient(app) as c:
        yield c

每个测试独立 app 实例时,overrides 互不影响,略增开销但并行更安全。

断言:状态码、JSON、Header

接口测试的断言通常分三层:

python
def test_create_item(client: TestClient) -> None:
    payload = {"name": "widget", "price": 9.9}
    response = client.post("/items", json=payload)

    assert response.status_code == 201
    body = response.json()
    assert body["name"] == "widget"
    assert "id" in body
    assert response.headers.get("content-type", "").startswith("application/json")

422 校验失败应显式测:

python
def test_create_item_invalid_price(client: TestClient) -> None:
    response = client.post("/items", json={"name": "x", "price": -1})
    assert response.status_code == 422
    errors = response.json()
    assert "detail" in errors or "error" in errors  # 依项目 envelope 而定

若项目用了统一错误体(上一篇),断言应针对 error.code 而非 Starlette 默认 detail 字符串。

非 JSON 响应(文件下载、plain text)用 response.contentresponse.text。Cookie 会话测 client.post("/login", ...) 后同一 client 自动带 cookie jar,无需手动拼 Header——测 JWT 则显式 Authorization: Bearer ...

带鉴权的请求

Header、Cookie、Query 与真实客户端一致:

python
def test_list_orders_requires_tenant(client: TestClient) -> None:
    no_header = client.get("/orders")
    assert no_header.status_code == 400

    ok = client.get("/orders", headers={"X-Tenant-Id": "tenant-a"})
    assert ok.status_code == 200
    assert isinstance(ok.json(), list)

测试环境可用固定 Token 或 override get_current_user 返回假用户,避免测试依赖外部 IdP。

python
@pytest.fixture
def auth_headers(client: TestClient) -> dict[str, str]:
    app.dependency_overrides[get_current_user] = lambda: User(id=1, tenant_id="t1")
    return {"Authorization": "Bearer test-token"}

fixture teardown 里同样 clear() overrides。

与数据库:事务回滚 fixture

集成测试常每用例一个事务,结束 rollback,库表保持干净:

python
@pytest.fixture
def db_session() -> Generator[Session, None, None]:
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    try:
        yield session
    finally:
        session.close()
        transaction.rollback()
        connection.close()

路由通过 dependency_overrides[get_db] 注入该 session,测试里 client.post 写入的数据不会提交到共享库——团队 CI 仍应用独立 test database URL,避免误连生产。

Alembic 迁移后的 schema 与 ORM 一致时,集成测试才可信。纯 SQLite 内存测不了 PG 特有类型时,CI 用 Docker 起 PostgreSQL service container,或本地 pytest -m integration 才连。

参数化与标记

同一逻辑多组输入用 @pytest.mark.parametrize

python
@pytest.mark.parametrize(
    "sku,expected_status",
    [("VALID-1", 200), ("MISSING", 404), ("", 404)],
)
def test_get_product(client: TestClient, sku: str, expected_status: int) -> None:
    response = client.get(f"/products/{sku}")
    assert response.status_code == expected_status

慢测试或需 Docker 的用例打 @pytest.mark.integration,日常 pytest -m "not integration" 只跑快路径。

pytest.raises 更适合直接调 Python 函数;HTTP 层用 assert response.status_code == 409。测 WebSocket 用 client.websocket_connect("/ws") 上下文管理器,与 HTTP TestClient 同一套 app。

OpenAPI 与 contract 抽检

/openapi.json 也可测:改字段后 schema 是否仍含某 path。不必每字段断言,关键 breaking change(删 required 字段)可一条 snapshot 或 jsonschema 校验。这是 TestClient 的延伸用法,不是替代业务断言。

失败消息怎么读

pytest 断言 assert response.status_code == 201 失败时,会打印实际 status 与 response body 片段。养成失败时 print(response.json()) 的习惯,422 时一眼看到 loc 指哪字段。依赖注入失败导致的 401/403,body 里常有 detailerror.code,对照 override 是否忘记清、Token fixture 是否过期。

测试与中间件、异常 handler 一起工作

TestClient 会穿过 CORS、计时等中间件,因此可以在集成测试里断言 X-Process-Time-Ms 存在。若 exception handler 把 404 包成自定义 envelope,TestClient 应用 assert body["error"]["code"] == "ORDER_NOT_FOUND",而不是假设 Starlette 默认 {detail: ...}。这保证生产链路与测试链路一致——不必 raise_server_exceptions=False 除非你要测 500 body 形状。

组织 growing 测试套件

用例变多后,按 api / services / repositories 分子目录(见测试方案篇)。共享 conftest.py 提供 clientdb_sessionauth_headers。命名 test_<动作>_<条件>_<期望>,如 test_create_order_insufficient_stock_returns_409。避免一个测试文件测整个应用;按 bounded context(订单、用户、库存)切文件,失败时定位更快。

fixture 作用域

@pytest.fixture(scope="module") 共享 TestClient 可加速,但 dependency_overrides 与 DB 状态易泄漏,默认 function scope 更安全。session 级 app 只适合纯只读、无 override 的 smoke。autouse=True fixture 做全局 reset 时要谨慎,隐藏测试间依赖。

测试 settings 与环境变量

monkeypatch.setenvSettings(_env_file=".env.test") 注入测试配置,避免读开发者本机 .env 连错库。create_app(settings=...) 模式让测试显式传 database_url="sqlite:///:memory:",比 patch os.environ 更清晰。

异步测试选型

全 async 项目可用 pytest-asyncio + AsyncClient;混合同步路由与 async 路由时 TestClient 仍够用。选一种风格写进团队 doc,避免同一 repo 两种 client 混用无约定。

测试数据隔离实践

并行 pytest -n auto 时,每个 worker 独立 DB schema 或 sqlite 文件(tmp_path_factory),避免自增 id 冲突。共享 PostgreSQL 实例时,用 transaction rollback 仍可能因 session 缓存 flaky——function scope session 最稳。清理 override、关闭 TestClient 放在 fixture finally,比 yield 后裸代码更不易漏。

文档化测试约定

在 README 或 CONTRIBUTING 写明:新路由必须带至少 happy path + 主要 4xx 的 TestClient 用例;新 Service 方法必须带单元测试。比事后补测更省力。

常见误解

只测 200。 4xx/5xx 与边界输入(空列表、分页最后一页)往往更容易回归;至少为鉴权、校验、404 各留一条。

断言整个大 JSON。 brittle 且难维护;断言关键字段与类型,或快照仅用于稳定 contract。

TestClient 等于压测。 它是功能正确性工具,并发与延迟要用 locust、k6 等另测。

忘记 lifespan。 依赖 startup 初始化的全局状态(连接池、缓存),测试要么走 lifespan,要么在 fixture 里显式 mock 初始化结果。

覆盖率与 pytest-cov

pytest --cov=myapp --cov-report=term-missing 看哪些路由从未被 TestClient 触及。覆盖率数字不是目标,但 uncovered 的 exception handler 分支值得补一条测试。与 mypy 并行:类型与测试互补,不能互相替代。

pytest + TestClient 解决「怎么测」;下一篇讨论测什么、哪里用 Mock、哪里该走真 DB 的取舍。

写第一个 TestClient 测试时,从 health 或 GET 只读接口开始,确认 fixture 与 app import 路径正确,再写 POST 带 DB 的用例。失败在 import 层时,常是 circular import 或缺少 test settings——与业务断言无关,却阻塞整个套件。保持测试 import 路径与运行时一致,用 myapp.main:app 工厂而非散落 rebuild app。

接口测试写得顺之后,把重复 payload、headers 收到 conftest 常量,减少 magic string。团队内共享 AUTH_HEADERSTENANT_A 等命名,读 case 时一眼知道场景。pytest 与 TestClient 是日常开发反馈环的核心,值得在 onboarding 第一天就搭好模板仓库。

小结

TestClient 让你在进程内以 HTTP 方式测 FastAPI,pytest fixture 组织 client、db 与 auth。断言 status、JSON envelope 与关键 header;与 dependency_overrides 配合隔离外部系统。从 health 测起,逐步覆盖 POST、422 与鉴权失败,形成 CI 反馈环。

初学者常问:接口测试要不要起 Docker?建议分层:日常 PR 用 SQLite 或 rollback 的 Postgres + TestClient;合并前可选 pipeline job 跑 testcontainers。关键是每个开发者本机 pytest 一条命令能在两分钟内绿,才会愿意先写测试后改代码。把 conftest 模板放进项目 cookiecutter,新服务 clone 下来就有 client/db fixture,比事后补文档更有效。

pytest 的 -k 表达式可只跑 orders 相关用例,改 Service 时缩短反馈。失败时加 --tb=short 保持输出可读。CI 缓存 .pytest_cache 不必,但应缓存 venv 依赖以加快安装。

把 pytest 与 TestClient 纳入 pnpm test 或 CI 同一 job,与前端 e2e 分开,失败定位更快。FastAPI 变更先跑 API 测试,再跑全仓。

Fixtures 可以链式依赖:db_sessionseed_ordersclient_with_orders,构建复杂 Arrange 时仍保持每个测试函数体短小。失败时 pytest 会打印 fixture 依赖链,便于定位 setup 问题。

每个路由至少一条 TestClient 用例,是团队可执行的最低测试标准。

集成测试失败时,先区分是 fixture 数据问题、override 未清理,还是业务回归——在断言前打印 response.json() 可省大量猜测时间。保持测试独立、可重复、快速,开发者才会在提交前本地运行全套 pytest,而不是只依赖 CI。

TestClient 与 pytest 是 FastAPI 项目质量门禁的默认组合,优先于手工 Postman 集合。

本地开发习惯:改路由后先跑相关 test 文件,再提交。

pytest 与 TestClient 应成为每个 FastAPI 项目的默认测试起点。