5.3 测试:TestClient与pytest体系


5.3 测试:TestClient 与 pytest 体系

FastAPI 的测试栈由三层组成:TestClient 提供免网络端口的请求模拟、pytest 夹具管理应用与数据生命周期、依赖覆盖(dependency_overrides)把数据库与认证替换为测试替身。三层配合实现"路由逻辑单测飞快、资源集成测少量而真实"的分层结构。本节从第一个测试写起,对照 Flask 测试客户端的异同,搭出完整骨架。

本节能力目标

阅读完本节,你应当能够:

  1. 写出第一个接口测试并解释 TestClient 不占端口的原因;
  2. 用 pytest 夹具组织应用、数据库与认证的测试上下文;
  3. 用依赖覆盖隔离数据库会话与当前用户;
  4. 区分路由单测与集成测试并规划比例;
  5. 针对第 4 章的错误体系写状态码与错误体断言。

第一个测试:三行跑通

from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_ping(): resp = client.get("/ping") assert resp.status_code == 200 assert resp.json() == {"pong": True}

没有端口监听、没有进程启动——TestClient 基于 httpx(Starlette 的实现),请求直接在进程内穿过完整的中间件链、路由、依赖、序列化,再原路返回。测试的是完整的应用行为,但速度是函数调用级的。对照 Flask 的 app.test_client():机制几乎相同(WSGI 层的进程内调用),FastAPI 版多的能力是它经过完整的 ASGI 生命周期——WebSocket、 lifespan 启动事件这些 ASGI 特性在测试里同样可用。pytest 的基本约定:文件名 test_ 前缀、函数 test_ 前缀、断言失败时 pytest 会展开两侧值,这三点足够跑通本节全部示例。

夹具:测试上下文的装配线

测试真实接口免不了"先有应用与数据"。pytest 夹具负责装配:

import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient from main import app from deps import get_session test_engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) TestSession = sessionmaker(bind=test_engine) @pytest.fixture def client(): Base.metadata.create_all(test_engine) test_session = TestSession() def override_session(): yield test_session app.dependency_overrides[get_session] = override_session with TestClient(app) as c: yield c app.dependency_overrides.clear() Base.metadata.drop_all(test_engine)

夹具的 yield 结构与依赖注入的 yield 是同一种思想:前半段装配、后半段拆卸,无论测试成败拆卸必然执行。内存 SQLite 让每个测试拿到干净的库,建表删表随测试生灭。第 3 章预告的 dependency_overrides 在此兑现——生产依赖 get_session 被替换为测试会话,路由代码零改动。

认证接口的测试捷径

测受保护接口,不必每个测试都先走一遍登录流程——直接覆盖 get_current_user:

from auth import get_current_user from models import User def fake_user(): return User(id=1, name="tester", is_admin=False) @pytest.fixture def auth_client(client): app.dependency_overrides[get_current_user] = fake_user yield client app.dependency_overrides.pop(get_current_user, None) def test_delete_user_forbidden(auth_client): resp = auth_client.delete("/users/2") assert resp.status_code == 403

普通用户删人被拒、403 语义正确——这个测试不涉及任何密码学与数据库,纯粹验证权限依赖的逻辑,运行耗时毫秒级。对照 Flask 世界测同样的事:要么构造完整请求上下文并 mock g,要么真的插入用户跑登录——前者脆弱后者缓慢。依赖覆盖把"认证"从每个测试的前置成本变成一行声明,这是依赖注入在测试侧的最大红利。

而登录接口本身(密码校验、令牌签发)要真实地测——不能覆盖掉被测对象。区分就在这:覆盖的是"被测接口的前置条件",绝不覆盖"被测逻辑本身"。混淆这两者是测试代码最常见的设计错误。

分层:单测快,集成少而真

合理的测试结构是一个金字塔。底层是路由单测(上面两节的形态:内存库加依赖覆盖,毫秒级,数量最多),中间是资源集成测(真实数据库容器,验证建表、迁移、SQL 方言,个位数到十几个),顶层是端到端冒烟(起完整服务打关键路径,三五个)。pytest 标记(marks)可以把层分开跑:日常开发只跑单测层,提交前加资源层,流水线全跑。

值得写的三类测试,比覆盖率数字更有指向性。错误路径测试:第 4 章的每个错误分支——无令牌 401、错令牌 401、权限不足 403、库存不足 409——各配一个断言(状态码加错误体结构)。正常路径大家都会写,事故都发生在错误路径上。边界值测试:Query 的 ge 与 le 边界、模型 min_length 的临界长度、分页的 0 与负数,边界值是 422 逻辑的试金石。契约快照测试:对关键接口的响应结构(字段集合与类型)做快照对比,防止有人无意中给 UserOut 加了字段或改了类型——response_model 挡住了服务端泄漏,挡不住"有意改模型忘了通知前端"的契约漂移。

def test_create_order_validation(client): resp = client.post("/orders", json={"items": []}) assert resp.status_code == 422 body = resp.json() assert body["detail"][0]["loc"] == ["body", "items"]

这条断言同时锁住了状态码与错误定位结构——前端通用错误处理器依赖这两个事实。错误体结构是接口契约的一部分,值得像正常响应一样被测试锁定。

常见问题与陷阱

测试间互相污染:dependency_overrides 忘记清理会泄漏到下一个测试。模式化解法是夹具的 teardown 段统一 clear(上面骨架已含),或者用 autouse 夹具保证每测必清。

异步路由怎么测:TestClient 对 async 路由透明——同步调用接口,内部自动跑事件循环。只有当你要直接 await 测试自己的 async 函数时才需要 pytest-asyncio,接口层测试不需要。

后台任务的断言:BackgroundTasks 在响应后执行,紧跟着的断言可能赶不上任务执行。TestClient 的上下文管理器形态(with 块)会在退出前等待后台任务完成——把断言放在 with 块内或使用该形态即可避免偶发失败。

一个完整示例:给订单接口写测试组

把本章工具对准前面的知识产物,写一组真实规模的测试。被测对象:创建订单接口——输入第 2 章的 CreateOrder 模型,挂第 3 章的 get_current_user 与 get_session,错误体系含 401、409 与 422(第 4 章)。四个测试各占一个关注点:

def test_create_order_ok(auth_client): resp = auth_client.post("/orders", json=VALID_BODY) assert resp.status_code == 201 assert resp.json()["id"] > 0 def test_create_order_401(client): # 未登录:真实依赖生效 resp = client.post("/orders", json=VALID_BODY) assert resp.status_code == 401 def test_create_order_422_empty_items(auth_client): resp = auth_client.post("/orders", json={**VALID_BODY, "items": []}) assert resp.status_code == 422 assert resp.json()["detail"][0]["loc"] == ["body", "items"] def test_create_order_409_stock(auth_client): seed_stock(sku="A-1001", stock=1) # 库存 1,买 2 resp = auth_client.post("/orders", json=VALID_BODY) assert resp.status_code == 409 assert resp.json()["code"] == "ORDER_STOCK_SHORT"

四条覆盖了从模型到依赖到错误码的整条链路:正常路径验证模型转换与状态码,认证边界用未覆盖的裸 client 让真实依赖把关,校验边界命中空列表的 min_length 约束(并锁定 loc 定位结构),业务冲突断言双层错误体系的错误码。测试命名把被测行为、输入特征、预期结果三层信息都编了进去——失败报告里读名字就知道事故现场,不需要翻测试体。

这组测试的运行时间在百毫秒级。这就是"测试分层"承诺的兑现:快不是偷工减料换来的,是把不需要真实的部分精确地换成替身。订单逻辑改动时,这四条几十秒内告诉你契约有没有被破坏;数据库方言升级时,再跑那十几个连真实库的集成测——两种变化、两种检出成本,分层让每种变化都付对了价钱。

图示:测试金字塔与依赖覆盖

图示:测试金字塔与依赖覆盖

常见问题速答

**问题:测试要跑真实数据库吗?**分层回答:路由单测用内存库加依赖覆盖(快、隔离);迁移与方言问题必须真实库(用容器起一次性数据库,测完即弃);两者之间的中间态按需真实库,但数量控制。判据始终是被测的东西需要多真——本章金字塔的每层都在回答这一个问题。

**问题:测试数据怎么组织不混乱?**两个习惯:工厂函数造数据(对每个模型写一个小工厂,参数覆盖关键字段,其余给默认值),测试里只声明自己关心的字段;每个测试自己种数据自己清(夹具的建表删表兜底),绝不依赖测试执行顺序。共享一大坨前置数据是测试耦合的温床,出现即拆。

**问题:覆盖率要多少?**数字本身不重要,分布重要:错误路径与边界值的覆盖比正常路径的百分比更值得投入(正文三类优先)。一个健康的信号是改坏任何一行关键代码都有测试变红——随手做几次这种突变实验,比看覆盖率百分比诚实得多。

**问题:持续集成里测试怎么分层跑?**标记分三层:默认层(单测,每次提交跑)、资源层(容器数据库,合并前跑)、端到端(每日定时跑)。本地开发只跑默认层,秒级反馈保住心流——测试的价值一半在发现缺陷,另一半在敢改,太慢的测试会同时毁掉两者。

本节要点回顾

  • TestClient 本质:进程内穿透完整 ASGI 链路,测的是完整行为、付的是函数调用成本。
  • 夹具即装配线:yield 前装后拆,与依赖注入共享同一思想;teardown 清理 overrides 防污染。
  • 认证捷径:覆盖 get_current_user 一行注入假用户,但只覆盖前置条件、永不覆盖被测逻辑。
  • 金字塔配比:路由单测海量、资源集成少量、端到端三五个,用 pytest 标记分层执行。
  • 三类优先:错误路径、边界值、契约快照,比覆盖率百分比更有指向性。
  • 后台任务时序:with 块形态保证退出前等待任务完成,消除偶发失败。

安全、任务、测试三块拼图就位,第 6 章进入外部集成:数据库、自动文档与静态资源。

延伸与边界

测试体系的边界与延伸。边界一:TestClient 测的是应用内行为,测不到进程外的东西——真实网络抖动、代理配置、证书问题属于端到端与预发环境的领地,别试图用单测模拟它们。边界二:测试不能证明正确,只能证明没发现错误——覆盖率百分比的迷信由此破除,第 7.4 节的突变实验思路才是质量的真实检验。延伸一:契约测试(第 6 章将展开的 OpenAPI 消费)是接口测试的自然升级,值得在接口稳定后引入。延伸二:性能回归测试(关键接口的延迟基线断言)把第 7 章的测量纪律自动化——三者合起来,测试就从"功能保险"成长为"全属性保险"。

写给从 Flask 迁来的读者一句收尾:Flask 时代的测试习惯(大量集成测、依赖完整上下文)不必整体照搬——依赖覆盖机制改变了测试的经济结构,同样的信心可以用十分之一的运行时间买到,重构测试结构本身应该是迁移工作单上的一项,而不是被遗忘的角落。

一个反模式的解剖

值得解剖一个真实见过的测试反模式:为了测"库存不足返回 409",测试作者启动了完整的应用、连上真实数据库、造了用户与商品、走了登录拿令牌、下单、断言——一条测试跑了四秒,全套两百条测试四十分钟,团队渐渐不再本地跑测试,只在持续集成里等结果,反馈回路断掉。解剖病灶:被测的是"库存判定与错误返回"这一小段逻辑,但测试为它付出了整个系统的启动成本。重构方案正是本章的三件套——依赖覆盖替掉数据库与登录,四秒变四十毫秒。反模式的教训浓缩成一句:测试的成本应当与被测逻辑的规模成正比,成本失控的测试不是更严谨,是设计失败的信号,它迟早会以"没人跑"的方式背叛你。

收尾给一个可执行的起步动作:今晚就给当前项目的三个核心接口各写一条正常路径与一条错误路径测试,接入持续集成的提交级流水。六条测试改变不了世界,但它们是测试文化的第一块砖——从零到六的门槛远高于从六到六十,跨过去之后,本章的其余内容会成为你自然而然的下一步,而不是待办清单上的一项。

  • 测试买的是敢改:绿着的测试套件让你敢重构、敢升级、敢发版——这份信心才是测试的最终产出,覆盖率只是过程指标。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U