pytest 测试
上一章给墨集 inkhub 的接口上了 JWT 的锁。锁是上好了,可新问题跟着来:改了点赞逻辑,「发布」会不会被顺手改坏?加了新栏目,「列表筛选」还认不认旧参数?靠人肉一遍遍点接口,点到第三天人就麻了——而且人会累、会漏…
上一章给墨集 inkhub 的接口上了 JWT 的锁。锁是上好了,可新问题跟着来:改了点赞逻辑,「发布」会不会被顺手改坏?加了新栏目,「列表筛选」还认不认旧参数?靠人肉一遍遍点接口,点到第三天人就麻了——而且人会累、会漏,机器不会。这一章把「验证」这件事整体外包给 pytest。
08 章埋的伏笔在这里兑现:app.dependency_overrides 换替身,正是为测试准备的;04 章的 get_db,也会在本章被换成测试会话。
本章你将学会
- pytest 的发现规则与原生 assert:写测试比写业务代码还省事
parametrize参数化:一组数据跑出一群用例- fixture:测试的「道具间」——前备后收、作用域、互相依赖
- pytest-asyncio 与
asyncio_mode = "auto":异步测试不再「假绿」- TestClient(同步)与 httpx AsyncClient(异步):两种姿势打自己的接口
- conftest.py:fixture 共享的家,同目录全免 import
- 依赖覆盖:测试里把
get_db、认证依赖换成假实现- Stub 与 Mock 的边界;单元 / 集成 / e2e 三层测试怎么分工
- 常用命令与 Makefile:测试挂进日常,一条命令全跑
本章配套 demo:
demo/test_inkhub.py(单文件自包含:内存假数据 app + 20 个测试,覆盖路由测试 / TestClient / 异步 / fixture / 参数化 / 依赖覆盖)+demo/conftest.py(共享 fixture)。示例场景延续「墨集 inkhub 内容平台」。运行方式(在本章目录下):
uv add --dev pytest pytest-asyncio httpx uv run pytest demo/test_inkhub.py -v
章节衔接:08 章 §11 的 dependency_overrides 在本章 §10 正式开工;04 章的 get_db 将在 §10 被换成测试会话;本章搭好的测试基建,13 章讲 loguru 时正好复用。
1. 为什么写测试:三笔账
写测试前先算三笔账,算完就知道这时间花得值不值。
第一笔:改代码的胆气。 没有测试,每次重构都是赌博——改了 50 个文件,你只能「希望」没改坏。有了测试,改完跑一遍,全绿就是「没改坏」的收据。这不叫防 bug,这叫防回归:同一个坑,摔第二次是不可原谅的。
第二笔:接口的契约。 测试是一份「可执行的文档」。test_create_title_length[空标题] 白纸黑字锁死了契约:/api/articles 传空标题必须 422。将来谁手滑改坏了这条约定,测试当场翻脸——比用户在群里@你体面多了。
第三笔:验证的效率。 手动 curl 验证十条接口要一分钟,pytest 一秒钟。省下的还不只是时间:人是会看走眼的,201 和 200 瞄一眼就过去了;assert 不会。
打个比方:测试套件是工地四周的安全网。平时你觉得它碍事——拉网子多花半天工期;等你从脚手架上摔下来那一刻(改坏线上功能),才知道网子值多少钱。测试的成本是线性的,回归的代价是指数的。
2. pytest 三分钟上手:发现规则与 assert
作用:pytest 不需要注册、不需要类继承、不需要测试基类——它靠命名约定找到你的测试。
发现规则三条(背下来,「collect 到 0 个用例」的问题九成栽在这):
- 文件:
test_*.py或*_test.py - 类:
Test开头,且没有__init__ - 函数 / 方法:
test_开头
原生 assert:直接写 assert,不用 self.assertEqual 那一套。pytest 会做「断言改写」——失败时自动把表达式两边的值都掏出来给你看。
最小示例(墨集的纯函数,demo 里真有这段):
def build_slug(title: str) -> str:
"""标题转 URL slug:小写 + 空格换连字符"""
return "-".join(title.lower().split())
def test_build_slug() -> None:
assert build_slug("Hello InkHub World") == "hello-inkhub-world"
运行:uv run pytest demo/test_inkhub.py -v。失败了你会看到:
> assert build_slug("Hello InkHub World") == "hello-inkhub-world"
E AssertionError: assert 'hello-inkhub-world' == 'hello world'
E - hello-inkhub-world
E + hello world
左右值自动展开,不用自己打 print。
断言异常用 pytest.raises:
import pytest
from pydantic import ValidationError
def test_missing_title_rejected() -> None:
with pytest.raises(ValidationError):
ArticleIn(title="") # min_length=1 不放行
常见坑
- 文件起名
check_inkhub.py→ 一个用例都收不到,pytest 沉默不语。 Test类里手贱写了__init__→ 整个类被静默跳过(这是为了让收集阶段零成本)。assert resp.json()["likes"] == 3失败时只告诉你2 != 3,想知道响应长啥样?给 assert 加个尾巴:assert resp.json()["likes"] == 3, resp.text——第二个参数是失败时打印的说明。- 测试函数别 return 值(pytest 会警告);要产出,yield 给 fixture 或断言掉。
3. parametrize:一组数据跑一群用例
作用:同一套测试逻辑喂多组数据,每组数据算一个独立用例——哪组挂了只挂哪组,互不连坐。
签名:
@pytest.mark.parametrize(
argnames, # "title, expected" 或 ("title", "expected")
argvalues, # [ (v1, v2), ... ] 每组一个元组,顺序对齐 argnames
ids=None, # 每组的名字,-v 显示用(强烈建议写)
)
def test_xxx(self, client, title, expected): ...
最小示例(demo 实测代码:标题长度校验,一份数据三个用例):
@pytest.mark.parametrize(
("title", "expected_status"),
[
("标题正常", 201),
("", 422), # 空:过不了 min_length=1
("x" * 51, 422), # 超长:过不了 max_length=50
],
ids=["正常标题", "空标题", "超长标题"],
)
def test_create_title_length(self, client: TestClient, title: str, expected_status: int) -> None:
resp = client.post("/api/articles", json={"title": title})
assert resp.status_code == expected_status
-v 跑出来的名字:test_create_title_length[空标题]——谁挂了一眼定位;不写 ids,你看到的是 test_create_title_length[title2],还得数是第几个。
堆叠 = 笛卡尔积:两个 parametrize 装饰器叠着用,用例数相乘:
@pytest.mark.parametrize("channel", ["前沿速递", "新手村"]) # 2 种栏目
@pytest.mark.parametrize("keyword", ["", "Python"]) # × 2 种筛选
def test_list(channel: str, keyword: str) -> None: ... # → 4 个用例
常见坑
argnames声明了 2 个,元组里只塞 1 个值 → 收集阶段直接 error。ids数量要和数据组数一致,错位了名字张冠李戴。- 笛卡尔积失控:两组各 10 条数据 = 100 个用例,跑一次泡杯茶。真需要组合才堆叠,否则拆成两个单维参数化。
4. fixture:测试的道具间
作用:把「准备环境」从测试体里抽出去。测试体只剩业务断言,杂活全归 fixture。
08 章说过 Depends 是 FastAPI 的「道具组」;fixture 就是 pytest 自己的道具间——一个伺候路由,一个伺候测试,连工作节奏都一样:开演前备好道具,散场后回收。
签名:
@pytest.fixture(
scope="function", # 作用域,见下表
autouse=False, # True = 同 scope 的测试自动使用,不用声明
name=None, # 改名,测试里用新名字取
)
def my_fixture() -> Iterator[SomeThing]:
准备...
yield thing # ⬅ 测试在这里跑
清理... # 测试结束才执行
最小示例(demo 里的同步客户端 fixture):
from collections.abc import Iterator
from fastapi.testclient import TestClient
@pytest.fixture
def client() -> Iterator[TestClient]:
with TestClient(app) as c: # 进 with:触发 lifespan 启动
yield c # ⬅ 测试在这里用客户端
# 出 with:lifespan 关闭——清理自动完成
yield 前是「备」,yield 后是「收」——和 08 章 yield 依赖的「借 / 还」是同一个节拍器。
作用域速查:
| scope | 活多久 | 典型住户 |
|---|---|---|
function(默认) |
每个测试重建一次 | 干净的数据、临时目录、客户端 |
class |
一个测试类共享 | 类内共用的配置 |
module |
一个文件共享 | 一份种子数据 |
session |
整个测试会话一次 | app 实例、连接池、只读样例数据 |
fixture 依赖 fixture:参数里直接写对方的名字,pytest 自动解析顺序:
@pytest.fixture
def async_client(app: FastAPI) -> AsyncClient:
... # app 是另一个 fixture,先建 app 再建客户端
autouse 实战(demo 的数据隔离——每个测试前后清空共享仓库):
@pytest.fixture(autouse=True)
def clean_repo() -> Iterator[None]:
_repo.clear() # 前置:清场
yield
_repo.clear() # 后置:再清场,别给下一个测试留垃圾
常见坑
- ScopeMismatch:大 scope 依赖小 scope 直接报错——session 级 fixture 不能依赖 function 级(它活得比人家久,后半程拿谁去?)。规则一句话:依赖只能向上找,不能向下找。
- autouse 是全局魔法:滥用之后「这个测试为啥有数据?」能查半天。像
clean_repo这种透明且无害的才配 autouse。 - session 级塞可变状态:第一个测试改了,第二个测试「莫名」看到脏数据。共享的必须只读(demo 的
sample_titles就是 session 级只读列表)。 - 清理代码写在
yield前面 = 演出还没开始就把道具收了——清理永远在 yield 后。
5. 装依赖、配 pytest:让 pytest 认识 async
问题:FastAPI 路由是 async def,测它自然想写 async def test_...。但原生 pytest 不认识异步测试——它只会把测试函数「调用一下」,拿到一个没人 await 的 coroutine,测试根本没执行。新版 pytest 会甩一句 PytestUnhandledCoroutineWarning,一屏幕黄字;老版本更阴险,直接给你看个「假绿」。
pytest-asyncio 就是补这块拼图的。安装(本章统一走 dev 依赖组):
uv add --dev pytest pytest-asyncio httpx
fastapi 本体在
dependencies里;TestClient 底层靠 httpx 驱动,正好一起装。
配置写进 pyproject.toml(demo 同款,可直接抄):
[tool.pytest.ini_options]
asyncio_mode = "auto" # 异步测试/fixture 全自动识别
asyncio_default_fixture_loop_scope = "function"
testpaths = ["demo"] # 裸跑 pytest 时的默认目录
两种模式:
strict(默认):每个异步测试、异步 fixture 都得打@pytest.mark.asyncio——显式但啰嗦;auto:全自动识别,写就是了。团队项目推荐 auto,省下的不是几行装饰器,是「忘了打标记导致假绿」的事故。
配好之后,异步测试长这样(demo 原文):
async def test_async_create_and_list(async_client: AsyncClient) -> None:
for title in ("异步第一篇", "异步第二篇"):
resp = await async_client.post("/api/articles", json={"title": title})
assert resp.status_code == 201
resp = await async_client.get("/api/articles")
assert [a["title"] for a in resp.json()] == ["异步第一篇", "异步第二篇"]
启动 -v 时输出里有一行 asyncio: mode=Mode.AUTO,说明插件已接管——看到它心里才踏实。
常见坑
- 装了插件、没配 auto、又没打标记 → 异步测试仍被当普通函数调,黄字警告刷屏。
pytest.ini/pyproject.toml/setup.cfg都能放 pytest 配置,但只认离测试文件最近的一份——两处都写,另一处就是摆设。- 配置改了不生效?pytest 对配置文件有缓存,重跑一次即可;实在诡异就
--cache-clear。
6. TestClient:同步打接口
作用:不发网络包,在进程内把 HTTP 请求直接递给 app。像大楼里的内部收发室——信不出大门,从三楼直接送到一楼收件人手里。
来源:from fastapi.testclient import TestClient(starlette 转手包装,底层驱动是 httpx——所以装依赖时 httpx 少不了)。
签名(常用参数):
TestClient(
app, # 被测应用
base_url="http://testserver", # 占位域名,测试里用相对路径即可
raise_server_exceptions=True, # 路由抛的异常直接在测试里炸出来(默认)
)
最小示例(demo 原文节选):
def test_get_missing_returns_404(self, client: TestClient) -> None:
resp = client.get("/api/articles/999")
assert resp.status_code == 404
assert resp.json()["detail"] == "文章不存在"
client.get / post / put / delete 的参数和 httpx 客户端一模一样:json=(请求体)、params=(query)、headers=(请求头)。
with 的意义——进触发 lifespan startup、出触发 shutdown:
with TestClient(app) as client:
... # app 若在 startup 里连数据库,必须这样包着用
demo 里这件事由 client fixture 代劳,测试函数只管伸手要。
常见坑
- 请求头的值只能 ASCII:
headers={"X-Inkhub-Editor": "林墨"}直接UnicodeEncodeError——HTTP 头不吃中文。中文身份放请求体里传(demo 的 conftest 里注释了这条,实打实踩出来的)。想传中文头得自己 RFC 编码,犯不上。 - 断言 5xx 场景:
raise_server_exceptions=True(默认)时,服务端异常不会变成响应而是直接抛——想断言「返回 500」得先关掉它。 - 同步 TestClient 别在 async 测试里用(会卡死事件循环报错)。异步测试请用 §7 的 AsyncClient——demo 里两种客户端泾渭分明。
7. httpx AsyncClient + ASGITransport:异步打接口
作用:异步版的「内部收发室」。两种情况用它:测试本身是 async(配合 §5 的 auto 模式),或者一个测试里要并发打多个请求。
签名与最小示例(demo 原文):
from collections.abc import AsyncIterator
from httpx import ASGITransport, AsyncClient
@pytest.fixture
async def async_client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app) # 内线电话:请求直通 app,不走网络
async with AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
测试侧:
async def test_async_like_increments(async_client: AsyncClient) -> None:
created = (await async_client.post("/api/articles", json={"title": "求点赞"})).json()
for _ in range(3):
resp = await async_client.post(f"/api/articles/{created['id']}/like")
assert resp.status_code == 200
assert resp.json()["likes"] == 3
选型一句话:同步测试用 TestClient(最省事),异步测试用 AsyncClient;一个项目两种共存完全正常——demo 里 TestSyncRoutes 和 TestAsyncClient 各用各的。
常见坑
- 忘传 transport 是头号事故:
AsyncClient()默认走真网络,base_url="http://testserver"又是个不存在的域名 →ConnectError。看到连接错误,先检查transport=ASGITransport(app=app)带没带。 - ASGITransport 测的是 app 本体:路由、依赖、中间件全部参与;但 uvicorn 进程层的东西(信号处理、真实端口、并发调度)不参与——那要 e2e 自己起服务(§12)。
- 离开
async with之后再发请求 →already closed。客户端的生命周期交给 fixture 管(§8),测试里别自己async with。
8. 用 fixture 管 app 与客户端
作用:app、客户端、数据隔离三件事收进 fixture 之后,测试体里就只剩「调接口 + 断言」,一行杂活都没有。
标准模板(demo 的完整三件套,直接抄):
@pytest.fixture(autouse=True)
def clean_repo() -> Iterator[None]:
_repo.clear() # ① 数据隔离:每个测试开跑前清场
yield
_repo.clear()
@pytest.fixture
def client() -> Iterator[TestClient]:
with TestClient(app) as c: # ② 同步客户端
yield c
@pytest.fixture
async def async_client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app) # ③ 异步客户端
async with AsyncClient(transport=transport, base_url="http://testserver") as c:
yield c
分工原则:
- app 全局一个(甚至可上
scope="session")——它就是被测对象本体; - 客户端 function 级——每次新拿一个,干干净净;
- 数据清场 autouse——测试互不串味是底线,demo 用
test_repo_starts_empty_each_test专门盯这条底线。
常见坑
- 客户端也做成 session 级 → 中间某个测试把 app 状态改脏(比如
dependency_overrides忘了清),殃及后面全体。 - 在测试函数里直接
TestClient(app)不走 fixture:能跑,但 lifespan 管理、清理逻辑全散装——文件一多必乱。 - 造数逻辑(先 POST 三篇文章再测列表)每个测试重抄一遍?抽成
_seed()辅助函数或 fixture(demo 用的_seed)。
9. conftest.py:共享 fixture 的家
作用:多个测试文件共用的 fixture,集中安放在 conftest.py 里。生效规则:它所在目录及其所有子目录的测试文件自动可见——不需要(也不能)import。
demo 里 editor_headers、sample_titles 都住在 demo/conftest.py,而 test_inkhub.py 里直接当参数用,文件顶部找不到一行相关 import——这就是 conftest 的全部魔法。
层级与就近覆盖(真实项目的经典布局):
test/
├── conftest.py # 全局共享:app、client、db_session
├── unit/
│ ├── conftest.py # 单元层专用;同名 fixture 覆盖父级
│ └── test_slug.py
└── integration/
├── conftest.py # 集成层专用:连测试库的 session
└── test_articles.py
- 父目录 conftest 对子目录一样生效;
- 子目录定义同名 fixture,就近覆盖——integration 可以有自己风格的
client而不动别人。
什么该进 conftest:≥ 2 个文件共享的才搬进去。只有一个文件用的 fixture 留在文件里——conftest 不是垃圾抽屉,什么都塞的话它自己就会变成维护灾难。
常见坑
- 文件名是铁律:
conftest.py少一个字母、加一个 s,整个目录的共享全失效。 - 在 conftest 里 import 测试模块是倒挂的:真实项目 app 住在独立模块(
app/main.py),conftest import app 天经地义;但反过来 import 测试文件,加载顺序立刻成一锅粥。 - 同名覆盖是特性也是坑:起名带上语境(
integration_client而不是也叫client),别让「覆盖」悄悄发生。
10. dependency_overrides:给测试换替身(08 章伏笔兑现)
08 章 §11 卖的关子,这章正式开工。app.dependency_overrides 是一张全局替换表:key 是原依赖的函数对象,value 是替身。换上之后,路由毫无感知——它不知道递到手上的道具是真是假。
场景一:换掉 get_db(呼应 04 章)——集成测试不想动真库?给它一个测试会话:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def override_get_db() -> AsyncIterator[AsyncSession]:
yield test_session # 测试专用会话:连测试库 / 每测回滚,随你设计
app.dependency_overrides[get_db] = override_get_db
04 章的 get_db 是 yield 依赖,替身也得是生成器——形状对齐,行为才对齐。
场景二:换掉认证(呼应 11 章)——不想每个测试都先造 token?
app.dependency_overrides[get_current_user] = lambda: User(name="tester", role="editor")
一行,全部受保护接口在测试里畅通无阻。
场景三:换掉仓库(demo 实测)——预置好数据的假仓库直接挂上,一篇不用先创建:
def test_override_repo_with_prefilled_fake(self, client: TestClient) -> None:
fake = MemoryRepo()
fake.create(ArticleIn(title="预置文章A"), editor="历史编辑")
fake.create(ArticleIn(title="预置文章B"), editor="历史编辑")
async def get_fake_repo() -> AsyncIterator[MemoryRepo]:
yield fake
app.dependency_overrides[get_repo] = get_fake_repo
try:
resp = client.get("/api/articles")
assert [a["title"] for a in resp.json()] == ["预置文章A", "预置文章B"]
finally:
app.dependency_overrides.clear()
标准姿势一:try / finally(单测试临时换,如上)。
标准姿势二:fixture 收口(多个测试共用同一替身——登记和清理收进 fixture,测试体只管断言):
@pytest.fixture
def fake_editor_client(client: TestClient) -> Iterator[TestClient]:
app.dependency_overrides[get_current_editor] = lambda: "替身编辑"
yield client # 测试期间:替身生效
app.dependency_overrides.clear() # 测试结束:自动还原
常见坑(08 章划过重点,测试语境再敲一遍)
- key 必须是传给
Depends的那个函数对象本身。传错了(比如传了别名)替换静默失效——测试还在打真库,你还以为绿了。 - 全局字典,用完必还:不
clear(),替身顺着 app 泄漏给后面所有测试。测试顺序一变结果跟着变,这种「顺序依赖型失败」是排查地狱——要么 try/finally,要么 fixture 收口。 - 替身签名尽量与原件一致(返回类型兼容、yield 依赖配生成器替身),否则错误出现在离现场很远的地方。
11. Stub 与 Mock:替身的两副面孔
§10 解决的是「怎么塞替身」,这节解决「替身长什么样」。
Stub(测试桩):只会给固定答案的哑巴道具。你问它「通知发了吗」,它永远答「发了」。测试只关心我的代码在假答案下能不能走对路——验证的是结果。
Mock:带执法记录仪的替身。不但替你答,还把「谁、几点、问了什么」全记下来——测完回头能验证交互本身:调了几次、参数对不对、顺序乱没乱。
墨集场景:文章过审后要给作者发站内通知,NotifyService 是外部服务,测试不能真发。
class NotifyService:
def send(self, editor: str, message: str) -> bool: ...
def approve(article: Article, notify: NotifyService) -> None:
article.status = "approved"
notify.send(article.editor, "你的文章过审了")
Stub 版——用 pytest 自带的 monkeypatch,零额外依赖:
def test_approve_with_stub(monkeypatch) -> None:
# Stub:send 不真发、永远报成功——我只想验证 approve 自己的逻辑
monkeypatch.setattr(NotifyService, "send", lambda self, editor, msg: True)
article = Article(title="异步指北", status="draft")
approve(article, NotifyService())
assert article.status == "approved" # 验证结果:状态流转对了
Mock 版——标准库 unittest.mock.MagicMock,同样零依赖:
from unittest.mock import MagicMock
def test_approve_with_mock() -> None:
notify = MagicMock() # 带「记录仪」的替身
article = Article(title="异步指北", editor="linmo", status="draft")
approve(article, notify)
# 验证行为:send 恰好被调用一次、参数一字不差
notify.send.assert_called_once_with("linmo", "你的文章过审了")
选型口诀:验证「结果」用 Stub,验证「行为」用 Mock。 绝大多数场景 Stub 就够。别为了炫技上 Mock——断言一堆调用细节的测试极脆,重构必红(参数改个名就崩)。
工具三选一:
| 工具 | 来源 | 擅长 |
|---|---|---|
monkeypatch |
pytest 内置 | 替换属性、环境变量、字典项,测试结束自动还原 |
unittest.mock |
标准库 | 断言交互(调用次数 / 参数 / 顺序) |
pytest-mock |
第三方插件 | mocker fixture,包装前两者,语法更顺 |
12. 测试分层:单元 / 集成 / e2e
拿工厂出厂检验打比方,正好三道关:元器件抽检(单元)→ 主板通电联调(集成)→ 整机烤机(e2e)。
| 层 | 测什么 | 外部依赖 | 速度 | 数量 |
|---|---|---|---|---|
| 单元 unit/ | 一个函数 / 类 / Pydantic 模型 | 全断(Stub / Mock / 纯内存) | 毫秒 | 多 |
| 集成 integration/ | 路由 + 依赖 + 仓库 多模块协作 | 测试库,或 override 成假仓库 | 秒 | 中 |
| e2e/ | 真实起服务的完整业务链 | 全真:真服务、真端口、真链路 | 分钟 | 少 |
各层在 demo 里的对应:TestBuildSlug 是单元(纯函数,毫秒级);TestSyncRoutes / TestAsyncClient 是集成(走完整 HTTP 栈,但仓库是内存假的)。真 e2e 的典型做法——conftest 里用 subprocess 起真服务,等 /docs 就绪再打真端口:
# e2e/conftest.py 骨架:起真服务 → 等就绪 → 测完回收
import subprocess, time, httpx
_server = subprocess.Popen(
["uv", "run", "fastapi", "dev", "app/main.py", "--port", "18000"]
)
for _ in range(50): # 最多等 5 秒
try:
if httpx.get("http://localhost:18000/docs").status_code == 200:
break
except Exception:
time.sleep(0.1)
@pytest.fixture
async def real_client():
async with httpx.AsyncClient(base_url="http://localhost:18000") as c:
yield c
边界与取舍:
- 能单元就别集成,能集成就别 e2e——速度差三个数量级,稳定性差更多(e2e 挂了可能是环境问题,单元挂了几乎必然是代码问题)。
- 冒烟测试(smoke):不是第四层,而是从各层里挑「挂了就是灾难」的用例打标记——登录、发文、支付这种核心链路。上线前只跑这批,几十秒出结果。做法:
pytest.mark.smoke+ 在配置里注册 marker,pytest -m smoke单独跑。 - 覆盖率(
pytest-cov插件)当体检报告看:哪里从没被测过一目了然;当 KPI 硬凑就会逼出一堆assert resp.status_code == 200的废测。 - 开发顺序顺带一提:TLD(先写代码后补测试,日常默认)与 TDD(先写测试再写实现,核心算法、复杂校验逻辑值得这么干)。
13. 常用命令速查
demo 全部实测过,拿去就能用(把路径换成你自己的):
| 命令 | 干什么 |
|---|---|
uv run pytest |
全量跑(目录来自 testpaths 配置) |
uv run pytest demo/test_inkhub.py |
只跑一个文件 |
uv run pytest demo/test_inkhub.py::TestSyncRoutes |
只跑一个类 |
uv run pytest demo/test_inkhub.py::TestSyncRoutes::test_get_missing_returns_404 |
只跑一个函数 |
-v |
每条用例一行——谁过谁挂一目了然 |
-s |
放行 print(默认会被捕获,想看中间变量时用) |
-k "async" |
按名字过滤:-k "async and not like" 也行 |
-x |
第一个失败就停(修 bug 时省时间) |
--tb=short |
traceback 只留关键帧(默认 long 太啰嗦) |
--tb=line |
一行定位失败位置 |
--lf |
只重跑上次失败的(改完代码二连击:--lf 验证) |
-m smoke |
只跑带标记的(需先注册 marker) |
--durations=5 |
最慢的 5 条用例——揪耗时大户 |
实测感受一下 -k 的过滤(demo 真实输出):
$ uv run pytest demo/test_inkhub.py -k "async" -q
2 passed, 18 deselected in 0.17s
常见坑:-k 匹配的是用例全名(含参数化的 ids)——-k 空标题 能精确命中那组参数化用例;写 -k test_xxx 反而多余,直接给节点 ID 更快。
14. Makefile:把测试挂进日常
命令背不住没关系,Makefile 替你背。放在项目根目录:
# Makefile —— 测试命令收编
.PHONY: test test-unit test-integration test-e2e test-smoke
test:
uv run pytest -v
test-unit:
uv run pytest test/unit/ -v
test-integration:
uv run pytest test/integration/ -v
test-e2e:
uv run pytest test/e2e/ -v
test-smoke:
uv run pytest test/ -m smoke -v
用法与回报:
- 日常开发:
make test-unit(秒级反馈,改个函数跑一遍); - 提交前:
make test(单元 + 集成全绿再提交); - 发版前:
make test-e2e(整条链路真实跑一遍)+make test-smoke(核心生命线最后确认)。
§12 讲的「分层」到这一步才真正兑现——分层的意义不只是组织代码,是让你在不同时机付得起不同的验证成本。
常见坑:Makefile 的缩进必须是 Tab,混进空格直接 missing separator;.PHONY 声明别省,否则目录里恰好有个叫 test 的文件夹时,make test 会以为「没事可做」。
15. 本章速查表
| 要点 | 一句话 |
|---|---|
| 发现规则 | 文件 test_*.py、类 Test* 无 __init__、函数 test_*——差一个字母都收不到 |
| assert | 原生断言失败自动展开左右值;加消息 assert x == y, resp.text |
| parametrize | 一组数据一群用例;ids 让 -v 可读;堆叠 = 笛卡尔积 |
| fixture | 道具间:yield 前备、后收;scope 从 function 到 session;依赖只能向上找 |
| asyncio | pytest-asyncio + asyncio_mode = "auto",异步测试免标记不假绿 |
| TestClient | 进程内同步打接口;with 触发 lifespan;请求头值只能 ASCII |
| AsyncClient | ASGITransport(app=app) 内线直通;忘传 transport 就打真网络 |
| 客户端 fixture | app 全局一个、客户端 function 级、清场 autouse |
| conftest | 同目录 + 子目录自动共享,免 import;同名就近覆盖;≥2 文件共用才进 |
| overrides | app.dependency_overrides[原依赖] = 替身;key 是函数对象;用完必 clear |
| Stub / Mock | 验证结果用 Stub、验证行为用 Mock;monkeypatch / MagicMock / pytest-mock |
| 分层 | 单元多而快、e2e 少而慢;冒烟是生命线;覆盖率先看没测过的角落 |
| 命令 | -v -k -x --tb=short --lf;单函数直接 文件::类::函数 |
| Makefile | 分层兑现为不同时机的验证成本;缩进必须 Tab |
测试全绿只说明「没改坏」;想知道服务运行时到底发生了什么——谁在什么时候调了哪个接口、慢在哪一步、哪个异常被吞了——你需要给系统装上「黑匣子」。下一章:loguru,十行配置换来一手看得见的运行现场。
下一章:13 · 日志 loguru