Python 框架指南 · 第 12 章

pytest 测试

上一章给墨集 inkhub 的接口上了 JWT 的锁。锁是上好了,可新问题跟着来:改了点赞逻辑,「发布」会不会被顺手改坏?加了新栏目,「列表筛选」还认不认旧参数?靠人肉一遍遍点接口,点到第三天人就麻了——而且人会累、会漏…

约 42 分钟16,677 字30 个代码示例

上一章给墨集 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 个用例」的问题九成栽在这):

  1. 文件:test_*.py 或 *_test.py
  2. 类:Test 开头,且没有 __init__
  3. 函数 / 方法: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