pytest:30 秒跑完 100 个用例的测试框架¶
各位写代码的时候,是不是经常被这种问题烦到——
「这个函数我刚才改了一行,会不会把别的地方搞挂?」
「老板让加个新功能,可这一坨代码我都不敢动,万一改坏了线上炸了怎么办?」
「同事给我提了个 PR,看起来没毛病,可我心里没底。」
这种「不敢动代码」的恐惧,老司机都懂。水哥当年第一次维护一个三千行的老项目,每改一行都心惊胆战,因为没有测试,谁也不知道改完会不会出事。最后只能小心翼翼地手动点几个页面、构造几个请求看看,跑十分钟才敢提交。效率低得感人。
「我代码改一改没事吧?」这个问题的答案永远是——跑一下测试才知道。
那写测试要多麻烦呢?很多童鞋一听「写测试」就头疼,觉得是给自己加活儿。其实在 Python 圈,有一个工具能让各位 30 秒跑完 100 个测试用例,写一个测试只要三行代码——它就是 pytest。
pytest 是 Python 圈测试的事实标准。Django、FastAPI、Pandas、NumPy、SQLAlchemy、Pydantic、Requests、httpx、几乎你能叫出名字的 Python 项目都用它来跑测试。GitHub 上 12k+ 的 stars,PyPI 上每月几亿次下载量。
这一章咱们把 pytest 从零讲到能上手。学完之后,各位应该能:
- 写出第一个
pytest测试,跑起来看到绿色的 PASS - 看懂别人项目里
tests/目录在干啥 - 用
fixture、参数化、tmp_path、monkeypatch这些核心工具把测试写得又简洁又强壮 - 给自己的项目接上覆盖率,知道哪行代码还没被测到
- 把测试接进
CI,提交 PR 自动跑
走起。
第一个测试¶
各位先来感受一下 pytest 有多简单。建一个新目录:
写一个最普通的 Python 文件 mymath.py:
然后写一个测试文件 test_mymath.py:
from mymath import add, divide
def test_add():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, 1) == 0
def test_divide():
assert divide(10, 2) == 5
注意三个细节——文件名以 test_ 开头、函数名以 test_ 开头、断言用 Python 内置的 assert 关键字。就这么简单。
跑一下:
输出大概长这样:
============================= test session starts ==============================
platform darwin -- Python 3.13.1, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/walter/test-demo
collected 3 items
test_mymath.py ... [100%]
============================== 3 passed in 0.01s ===============================
三个绿点 ... 代表三个测试全部通过。0.01 秒跑完。
是不是比预想中简单?
各位再故意把 add 函数改坏,比如改成 return a + b + 1,再跑一次:
test_mymath.py FF. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
> assert add(2, 3) == 5
E assert 6 == 5
E + where 6 = add(2, 3)
test_mymath.py:4: AssertionError
pytest 直接告诉你:第几行的断言炸了、左边是 6、右边是 5、6 是怎么来的(add(2, 3) 算出来的)。一目了然。这就是 pytest 的「assert 重写」黑科技——后面会专门讲。
pytest vs unittest——为什么选 pytest¶
各位可能会问:「Python 不是自带 unittest 标准库吗?为什么还要装 pytest?」
好问题。咱们对比一下同一个测试,两种写法的区别。
unittest 写法:
import unittest
class TestMyMath(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
def test_divide(self):
self.assertEqual(divide(10, 2), 5)
if __name__ == "__main__":
unittest.main()
pytest 写法:
差别在哪?
- 不需要继承
TestCase。pytest用普通函数就行,少写一层类 - 不需要记一堆断言方法名。
unittest有assertEqual、assertNotEqual、assertTrue、assertFalse、assertIn、assertIsInstance、assertGreater、assertRaises…… 几十个方法都得记。pytest只用一个assert就完事 - 不需要写
__main__。直接pytest命令一跑就完了
unittest 风格其实是从 Java 的 JUnit 抄过来的,「类 + 方法」这一套是 OOP 思维。Python 这种动态语言用普通函数就能搞定的事,没必要搞这么重。所以 2010 年后,Python 圈逐步全面倒向 pytest。
那 unittest 还能用吗?能。pytest 是向后兼容 unittest 的,你写的 unittest 测试用例,pytest 直接也能跑。所以老项目从 unittest 迁过来很丝滑——一行代码不改,把跑测试的命令从 python -m unittest 换成 pytest 就行。新项目则建议直接全用 pytest 风格。
各位记一句话:Python 圈如果现在还有人推荐用 unittest 写新项目,要么是十年前的资料,要么是没跟上时代。直接 pytest。
assert 重写——pytest 最神奇的地方¶
刚才咱们看到,pytest 的失败信息特别详细:
按理说 assert 这个东西很「弱」——它失败的时候只能抛 AssertionError,啥提示都没有。Python 内置的 assert 出错时是这样的:
>>> x = 6
>>> assert x == 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
只告诉你「断言失败了」,至于左边右边各是啥,不知道。
那 pytest 怎么搞出来这么多信息的?
答案是「assert 重写」(assertion rewriting)。pytest 在加载测试文件之前,会先把代码里的 assert 语句重写一遍——把简单的 assert a == b 改写成「先把 a 和 b 算出来存好,断言失败时再把它们打印出来」。这套机制不需要各位写任何额外代码,开箱即用。
复杂一点的断言也照样有详细信息:
跑一下,pytest 会输出:
它甚至会告诉你「在第 4 个位置,5 跟 6 不一样」。字典、字符串、集合都有类似的「智能 diff」。
各位有没有觉得「这个比 unittest.TestCase.assertEqual 都要友好」?没错——很多年前 unittest 系列工具喜欢用 assertEqual 之类的方法,就是因为「裸 assert 信息太少」。pytest 用元编程把这个问题彻底解决了。
所以 pytest 圈推荐的写法只有一句话——所有断言都用 assert,一律不用 assertXXX。
咱们看看几种常见的断言:
# 相等
assert x == 5
assert name == "两点水"
# 不等
assert x != 0
# 包含
assert "Python" in "I love Python"
assert 3 in [1, 2, 3]
# 真假
assert is_valid is True
assert result is None
# 数值大小
assert age >= 18
assert 0 < score < 100
# 类型
assert isinstance(x, int)
要点就是「写普通 Python 表达式」。能用 == 就用 ==,能用 in 就用 in,不用记任何专属断言函数。这就是各位写 pytest 测试时最舒服的地方。
测试发现——pytest 怎么找到你的测试¶
各位刚才有没有注意到一件事?我们写 pytest 命令时没指定要跑哪个文件,但它自动找到了 test_mymath.py。这是怎么做到的?
pytest 有一套默认的「测试发现」(test discovery)规则。它会从当前目录开始,递归扫描所有目录,找符合下面这些规则的东西:
- 测试文件:
test_*.py或*_test.py - 测试类:以
Test开头的类(且没有__init__方法) - 测试函数:以
test_开头的函数或方法
举几个例子:
my-project/
├── src/
│ └── mymath.py
└── tests/
├── test_mymath.py # 自动发现 ✓
├── test_helper.py # 自动发现 ✓
├── helper_test.py # 自动发现 ✓
└── notes.py # 不识别 ✗
测试文件里:
def test_add(): # 自动发现 ✓
pass
def helper(): # 不识别 ✗
pass
class TestUser: # 自动发现 ✓
def test_create(self): # 自动发现 ✓
pass
def helper(self): # 不识别 ✗
pass
class UserTest: # 不识别 ✗(不是 Test 开头)
pass
各位写测试的时候,约定俗成的目录结构是这样:
my-project/
├── pyproject.toml
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── core.py
│ └── utils.py
└── tests/
├── test_core.py
└── test_utils.py
源码放在 src/ 下,测试放在 tests/ 下,测试文件名跟被测文件一一对应。core.py 对应 test_core.py,utils.py 对应 test_utils.py。这样新人接手项目,看到 core.py 想知道它的测试在哪,一眼就能找到。
那 pytest 命令怎么用?常用几种姿势:
# 跑所有测试
pytest
# 只跑某个文件
pytest tests/test_core.py
# 只跑某个测试函数
pytest tests/test_core.py::test_add
# 只跑某个类的某个方法
pytest tests/test_core.py::TestUser::test_create
# 按关键字过滤(函数名包含某个词)
pytest -k "add"
pytest -k "add or divide"
pytest -k "not slow"
# 显示更详细的输出
pytest -v
# 第一个失败就停
pytest -x
# 显示 print 输出(默认 pytest 会捕获)
pytest -s
-v 会把每个测试的名字列出来,绿勾代表通过:
test_mymath.py::test_add PASSED
test_mymath.py::test_add_negative PASSED
test_mymath.py::test_divide PASSED
-x 在 debug 时特别有用——一堆测试全挂了,与其全跑完再看,不如第一个挂就停下来分析。
-s 让 print 直接输出到终端,方便临时打日志看变量值。
fixture——测试界的依赖注入¶
各位写测试写多了,会发现一个问题:很多测试都需要一些「准备工作」。
比如:
- 测试数据库相关功能时,每个测试都要先建一个临时数据库连接
- 测试 API 时,每个测试都要先启动一个 HTTP 客户端
- 测试文件操作时,每个测试都要先准备一个临时目录
如果按「最朴素」的写法:
def test_user_create():
db = create_test_db()
db.connect()
try:
user = User(name="walter")
db.save(user)
assert db.get_user("walter").name == "walter"
finally:
db.close()
def test_user_delete():
db = create_test_db()
db.connect()
try:
user = User(name="walter")
db.save(user)
db.delete_user("walter")
assert db.get_user("walter") is None
finally:
db.close()
每个测试都要重复「建库 → 连接 → finally 关闭」这一坨。很丑。
pytest 的解决办法叫 fixture。它把「准备工作」抽成一个带 @pytest.fixture 装饰器的函数:
import pytest
@pytest.fixture
def db():
"""提供一个干净的测试数据库连接。"""
conn = create_test_db()
conn.connect()
yield conn # 把 conn 交给测试函数
conn.close() # 测试结束后再清理
def test_user_create(db):
user = User(name="walter")
db.save(user)
assert db.get_user("walter").name == "walter"
def test_user_delete(db):
user = User(name="walter")
db.save(user)
db.delete_user("walter")
assert db.get_user("walter") is None
各位看出门道了吗?
- 写一个
dbfixture,里面yield出去一个连接对象 - 测试函数把
db写成参数,pytest自动把 fixture 算出来的值传进来 - 测试结束后,
yield后面的清理代码自动跑
是不是有点像 Spring/Django 里的「依赖注入」?各位要啥东西,写个参数名 pytest 就帮你准备好。测试函数只关心「我要测什么」,不关心「怎么准备数据」——关注点完全分离。
那 fixture 还能怎么写?最常见的几种写法:
只准备、不清理:直接 return,省掉 yield。
@pytest.fixture
def sample_user():
return User(name="walter", age=28)
def test_greet(sample_user):
assert greet(sample_user) == "Hello, walter!"
fixture 之间可以互相依赖。比如 db 依赖 config:
@pytest.fixture
def config():
return {"host": "localhost", "port": 5432}
@pytest.fixture
def db(config): # 注意这里也写参数
conn = connect(config["host"], config["port"])
yield conn
conn.close()
def test_query(db):
assert db.ping() is True
pytest 会自己把整个依赖链解算出来,先跑 config,再用 config 的结果调 db,最后把 db 喂给测试。
fixture scope——什么时候建、什么时候销毁¶
各位再看一个实际问题——
「我有一个 fixture 是建数据库连接,建一次要 1 秒。我有 100 个测试都用它。每个测试都重新建一次的话,光建连接就要 100 秒。能不能只建一次,所有测试共用?」
pytest 的答案是 fixture 的 scope 参数。它有四档:
function(默认):每个测试函数跑前建、跑后销毁class:每个测试类跑前建、跑完销毁module:每个测试文件(模块)跑前建、跑完销毁session:整个pytest启动到结束只建一次
举个例子,把数据库连接设成 session 级别:
@pytest.fixture(scope="session")
def db():
conn = create_test_db()
conn.connect()
yield conn
conn.close()
这样不管你跑多少测试,conn 只建一次、关一次。
那啥时候用啥 scope?给各位一个经验法则——
- 要保证测试之间彼此独立(每个测试都从干净状态开始):用
function - 建一次代价很高(连数据库、启容器、加载大模型):用
module或session,但要注意每个测试结束后手动清理状态
举个混合用法:
@pytest.fixture(scope="session")
def db_connection():
"""整个 session 共用一个连接,建一次就够。"""
conn = connect_to_db()
yield conn
conn.close()
@pytest.fixture(scope="function")
def db(db_connection):
"""每个测试用一个干净的事务,跑完回滚。"""
txn = db_connection.begin_transaction()
yield txn
txn.rollback()
def test_create_user(db):
db.execute("INSERT INTO users ...")
# 测试结束后事务自动回滚,下一个测试看到的还是干净的库
「连接共享、事务隔离」是数据库测试里特别常见的模式。session 级别的连接保证速度,function 级别的事务保证隔离。
内置 fixture——pytest 自带的几个救命神器¶
各位先别急着自己写 fixture,pytest 自己已经备好了几十个常用的。说几个最最常用的。
tmp_path——临时目录¶
写测试经常要操作文件——读啥、写啥、删啥。直接在硬盘上随便建文件吧,跑完测试满地是垃圾;用 tempfile 自己管理吧,又得写一堆 setup/teardown。
pytest 的 tmp_path fixture 直接给你一个临时目录,跑完测试自动清理。
def test_write_and_read(tmp_path):
file = tmp_path / "hello.txt"
file.write_text("两点水", encoding="utf-8")
assert file.exists()
assert file.read_text(encoding="utf-8") == "两点水"
tmp_path 是一个 pathlib.Path 对象,直接 / 拼路径。各位回想一下 python18 讲过的 pathlib,这里完美闭环。
每个测试拿到的 tmp_path 都是不同的目录,互不干扰。pytest 默认会保留最近 3 次跑测试的临时目录在 /tmp/pytest-of-<user>/ 下,如果一个测试挂了,各位可以去那里翻翻看里面的文件长啥样,方便 debug。
monkeypatch——临时改环境¶
测试时经常要「修改一些全局状态」——改环境变量、替换某个函数、改某个对象的属性。改完测试结束之后还得改回去,不然影响下一个测试。
monkeypatch fixture 就是干这个的。它能临时改东西,测试结束自动还原。
def test_with_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert get_api_key() == "test-key-123"
# 测试结束后 API_KEY 自动还原
def test_replace_function(monkeypatch):
def fake_get_time():
return 1700000000
monkeypatch.setattr("mymodule.time.time", fake_get_time)
# 测试结束后 time.time 自动还原
def test_chdir(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
# 当前目录临时切到 tmp_path,测试结束自动切回去
常用方法:
monkeypatch.setenv(name, value):设环境变量monkeypatch.delenv(name):删环境变量monkeypatch.setattr(target, value):替换某个对象的属性monkeypatch.delattr(target):删某个属性monkeypatch.chdir(path):切当前目录
各位测试有外部依赖的代码时,monkeypatch 是首选——不用真去连数据库、不用真发 HTTP 请求,直接 fake 掉一个返回值就行。
capsys——抓 stdout/stderr¶
测试一段会 print 的代码,怎么验证它打印对了?capsys 就是干这个的。
def greet(name):
print(f"Hello, {name}!")
def test_greet(capsys):
greet("walter")
captured = capsys.readouterr()
assert captured.out == "Hello, walter!\n"
assert captured.err == ""
capsys.readouterr() 返回一个对象,.out 是 stdout、.err 是 stderr。读完之后 buffer 会被清空,再次调用就是新的输出。
类似的 fixture 还有:
capfd:抓文件描述符级别的输出(包括 C 扩展、子进程的输出)caplog:抓logging模块的输出
capsys 测「函数有没有正确 print」最方便。
参数化——同一个测试跑多组数据¶
各位写测试时是不是经常这样:
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -2) == -3
def test_add_zero():
assert add(0, 0) == 0
def test_add_mixed():
assert add(-5, 10) == 5
测一个加法,写四个函数,函数体几乎一模一样,只是数据不同。重复啊重复。
pytest 的参数化(@pytest.mark.parametrize)能把它压成一个:
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, -2, -3),
(0, 0, 0),
(-5, 10, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expected
跑出来 pytest 会把它当成 4 个独立测试:
test_mymath.py::test_add[2-3-5] PASSED
test_mymath.py::test_add[-1--2--3] PASSED
test_mymath.py::test_add[0-0-0] PASSED
test_mymath.py::test_add[-5-10-5] PASSED
挂了哪一组、过了哪一组,一目了然。
参数化的语法两个要点:
- 第一个参数是字符串,写测试函数要接收的参数名,多个用逗号分隔
- 第二个参数是列表,每一项是一组测试数据(参数顺序跟第一个字符串对应)
每组数据还能起个名字,方便看输出:
@pytest.mark.parametrize("a, b, expected", [
pytest.param(2, 3, 5, id="正数相加"),
pytest.param(-1, -2, -3, id="负数相加"),
pytest.param(0, 0, 0, id="零相加"),
])
def test_add(a, b, expected):
assert add(a, b) == expected
跑起来:
test_mymath.py::test_add[正数相加] PASSED
test_mymath.py::test_add[负数相加] PASSED
test_mymath.py::test_add[零相加] PASSED
各位用中文给 case 起名也完全没问题,看起来更直观。
参数化还能堆——多个 @pytest.mark.parametrize 叠加,会自动算笛卡尔积:
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_combo(x, y):
print(x, y)
跑出来一共 3 × 2 = 6 个用例:(1,10) (1,20) (2,10) (2,20) (3,10) (3,20)。
参数化是 pytest 最杀手锏的功能之一,能用参数化就尽量用,别复制粘贴写一堆相似的测试。
异常断言——测「会不会抛错」¶
写代码时,咱们经常希望某个函数在错误输入下「应该」抛异常。比如除以 0、空字符串、负数年龄。
那怎么测「这个函数应该抛 ValueError」呢?光写 try/except 不行——测试函数没出错也算通过,可逻辑上没抛异常恰恰是 bug。
pytest 提供了 pytest.raises 上下文管理器:
with pytest.raises(ValueError): 块里面的代码必须抛 ValueError,不抛或抛别的异常都算测试失败。
如果各位还想顺便检查异常的内容(比如错误信息):
match 参数是一个正则表达式,匹配异常的字符串表示。
或者拿到异常对象本身做更精细的断言:
def test_divide_by_zero_detail():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "除数" in str(exc_info.value)
assert exc_info.type is ValueError
exc_info.value 就是抛出来的那个异常对象,可以像普通对象一样访问它的属性。
各位写测试时,「正常路径」+「异常路径」都要覆盖。一个函数只测正常输入是不够的——边界条件、错误输入、空值这些「能否优雅地抛异常」也很重要。
跳过和标记——挑着跑测试¶
写测试时,各位经常会遇到这种场景:
- 这个测试只在 Linux 上能跑,Windows 上没法跑
- 这个测试只对 Python 3.11+ 有效
- 这个测试很慢(要十几秒),平时不想跑,CI 才跑
- 这个测试还在写,先标个「待办」
pytest 的解法是「mark」(标记)系统。
skip——直接跳过¶
import pytest
@pytest.mark.skip(reason="这个还没实现完")
def test_new_feature():
assert do_something() == "expected"
跑起来 pytest 会显示一个 s:
测试不会执行,也不会算失败。
skipif——条件跳过¶
import sys
@pytest.mark.skipif(sys.platform == "win32", reason="Windows 上不支持")
def test_unix_specific():
assert "/usr/bin" in PATH
@pytest.mark.skipif(sys.version_info < (3, 11), reason="需要 Python 3.11+")
def test_exception_group():
pass
第一个参数是表达式,为 True 就跳过。
xfail——预期失败¶
有些测试目前过不了,但又不想完全跳过。比如某个 bug 还没修,想让测试存在着提醒大家。
@pytest.mark.xfail(reason="bug #42 还没修")
def test_buggy_feature():
assert buggy_function() == "expected"
xfail 的逻辑是「我预期它会挂」:
- 如果挂了:算 PASS(符合预期)
- 如果意外通过了:算 XPASS(提醒你 bug 可能修好了)
自定义 marker——给测试分类¶
各位有时候想给测试分类。比如「慢测试」「需要数据库的测试」「网络测试」。
@pytest.mark.slow
def test_big_data():
process_one_million_rows()
@pytest.mark.db
def test_query_user():
pass
@pytest.mark.network
def test_fetch_api():
pass
然后命令行用 -m 挑着跑:
自定义 marker 用之前要先在配置里登记一下,不然 pytest 会警告。在 pyproject.toml 里:
这样写好处有两个——一是消除警告,二是新人来看 pyproject.toml 就知道项目里都有哪些测试分类。
覆盖率——哪些代码还没被测到¶
各位项目里写了一堆测试,跑起来全绿,开心。可是真的「测全了」吗?
「覆盖率」(coverage)这个概念就是用来回答这个问题的——到底有多少代码在测试中被执行过。100 行代码,测试只跑到了 60 行,覆盖率就是 60%。剩下那 40 行可能藏着 bug。
Python 圈测覆盖率的工具是 coverage,跟 pytest 配套用一般装 pytest-cov 这个插件,更简洁。
跑:
# 跑测试 + 输出覆盖率报告
pytest --cov=src
# 也可以指定包名
pytest --cov=mypackage
# 输出更详细的「哪几行没测到」
pytest --cov=src --cov-report=term-missing
输出大概长这样:
---------- coverage: platform darwin, python 3.13.1 -----------
Name Stmts Miss Cover Missing
-----------------------------------------------
src/mymath.py 12 2 83% 15-16
src/utils.py 8 0 100%
-----------------------------------------------
TOTAL 20 2 90%
看 Missing 那列——mymath.py 的第 15、16 行没被测到。各位拉过去看一下,很可能就是某个边界条件没覆盖到。
要更直观,生成 HTML 报告:
会在 htmlcov/ 目录下生成网页报告。打开 htmlcov/index.html,每个文件点进去能看到哪一行被测了(绿色)、哪一行没测(红色),特别直观。
pytest-cov 还能写到 pyproject.toml 里设默认参数:
这样以后跑 pytest 就自动带覆盖率报告。
那覆盖率多少算合格?社区的「常识值」是这样的——
- 80% 以上:基本及格线
- 90% 以上:项目质量比较高
- 100%:理想,但通常代价很高(要测各种「不可能发生」的分支)
水哥的建议是——别死追 100%。强追覆盖率会逼出来一堆「为了凑数而写的测试」,反而拖累项目。重点是关键业务路径、复杂逻辑、边界条件这些地方测全。覆盖率工具是用来「找漏」的,不是用来「打分」的。
各位不用一上来就给老项目设 90% 的硬指标。从 50% 起步、每个 PR 守住「不下降」这条底线,长期下来项目就会越来越健壮。
conftest.py——共享 fixture¶
各位写测试越写越多,会发现一个问题——好几个测试文件都要用同一个 fixture。
比如 test_user.py、test_order.py、test_product.py 都要一个 db fixture。难道每个文件都要写一遍?
不用。pytest 有一个特殊的文件叫 conftest.py,放在 conftest.py 里的 fixture 自动可被同目录及子目录下的所有测试使用——不需要 import。
举个例子:
tests/
├── conftest.py # 共享 fixture 在这里
├── test_user.py
├── test_order.py
└── api/
├── conftest.py # 子目录还能再加一份
└── test_endpoints.py
tests/conftest.py:
import pytest
@pytest.fixture
def db():
conn = create_test_db()
yield conn
conn.close()
@pytest.fixture
def sample_user(db):
user = User(name="walter", age=28)
db.save(user)
return user
tests/test_user.py:
def test_get_user(db, sample_user):
# 直接用,不用 import
assert db.get_user(sample_user.id).name == "walter"
tests/test_order.py:
def test_create_order(db, sample_user):
# 一样可以直接用
order = Order(user=sample_user, amount=100)
db.save(order)
assert order.id is not None
tests/api/conftest.py 还可以再补一份只对 api/ 子目录生效的 fixture:
import pytest
@pytest.fixture
def http_client():
from httpx import Client
with Client(base_url="http://localhost:8000") as c:
yield c
pytest 会从测试文件所在目录开始往上查找所有 conftest.py,把里面的 fixture 都收集起来。所以外层的 fixture 内层能用,反过来不行。
各位项目里搞清楚这个规则,测试代码会非常清爽——共享逻辑全在 conftest.py,每个测试文件只关注自己的事。
pyproject.toml 里配置 pytest¶
pytest 默认零配置就能用,但项目大了之后,几个常用配置写到 pyproject.toml 里能省很多事。
[tool.pytest.ini_options]
# 测试文件去哪找
testpaths = ["tests"]
# 默认参数(每次跑 pytest 都自动加上)
addopts = [
"-v", # 详细输出
"--strict-markers", # 用了未登记的 marker 报错
"--cov=src", # 跑覆盖率
"--cov-report=term-missing", # 显示未覆盖的行
]
# 自定义 marker 登记
markers = [
"slow: 慢测试",
"db: 需要数据库的测试",
"network: 需要网络的测试",
]
# 最低 Python 版本
minversion = "8.0"
老项目里如果是 setup.cfg 或 pytest.ini,迁移到 pyproject.toml 也很顺利——把 [pytest] 段改成 [tool.pytest.ini_options] 就行。pytest 8.x 之后 pyproject.toml 的支持已经非常成熟,新项目直接用它。
一个完整小实战——给 Wallet 类写测试¶
各位还记得 python17 讲类型注解时,咱们写过一个 Wallet 类吗?这次咱们把它写完整、再给它写一套像样的测试。
先看 wallet.py(升级版,多加了几个方法、加了简单的 IO 持久化):
from __future__ import annotations
import json
from pathlib import Path
class InsufficientFundsError(Exception):
"""余额不足时抛出。"""
class Wallet:
"""一个简单的钱包。"""
def __init__(self, initial: float = 0) -> None:
if initial < 0:
raise ValueError("初始余额不能为负数")
self.balance: float = initial
def deposit(self, amount: float) -> float:
if amount <= 0:
raise ValueError("存入金额必须大于 0")
self.balance += amount
return self.balance
def withdraw(self, amount: float) -> float:
if amount <= 0:
raise ValueError("取出金额必须大于 0")
if amount > self.balance:
raise InsufficientFundsError(
f"余额不足:当前 {self.balance},想取 {amount}"
)
self.balance -= amount
return self.balance
def can_afford(self, price: float) -> bool:
return self.balance >= price
def save(self, path: Path) -> None:
path.write_text(
json.dumps({"balance": self.balance}),
encoding="utf-8",
)
@classmethod
def load(cls, path: Path) -> Wallet:
data = json.loads(path.read_text(encoding="utf-8"))
return cls(initial=data["balance"])
各位看一眼这个类,有几个测试点:
- 初始化:负数初始余额应该抛
ValueError deposit:金额 ≤ 0 应抛ValueError;正常存入后余额加上withdraw:金额 ≤ 0 抛ValueError;超额抛InsufficientFundsError;正常取出后余额减去can_afford:低于、等于、高于余额三种情况save/load:写到文件再读回来,余额一致
接下来写测试 test_wallet.py:
import json
import pytest
from wallet import Wallet, InsufficientFundsError
# ---------- fixture ----------
@pytest.fixture
def empty_wallet() -> Wallet:
"""每个测试拿一个空钱包。"""
return Wallet()
@pytest.fixture
def rich_wallet() -> Wallet:
"""每个测试拿一个 1000 块的钱包。"""
return Wallet(initial=1000)
# ---------- 初始化 ----------
def test_default_balance(empty_wallet):
assert empty_wallet.balance == 0
def test_init_with_money():
w = Wallet(initial=500)
assert w.balance == 500
def test_negative_initial_raises():
with pytest.raises(ValueError, match="不能为负数"):
Wallet(initial=-1)
# ---------- deposit ----------
@pytest.mark.parametrize("amount, expected", [
(100, 1100),
(0.5, 1000.5),
(1, 1001),
])
def test_deposit(rich_wallet, amount, expected):
assert rich_wallet.deposit(amount) == expected
assert rich_wallet.balance == expected
@pytest.mark.parametrize("bad_amount", [0, -1, -100])
def test_deposit_invalid(empty_wallet, bad_amount):
with pytest.raises(ValueError, match="必须大于 0"):
empty_wallet.deposit(bad_amount)
# ---------- withdraw ----------
def test_withdraw_normal(rich_wallet):
assert rich_wallet.withdraw(300) == 700
assert rich_wallet.balance == 700
def test_withdraw_too_much(rich_wallet):
with pytest.raises(InsufficientFundsError) as exc_info:
rich_wallet.withdraw(2000)
assert "余额不足" in str(exc_info.value)
@pytest.mark.parametrize("bad_amount", [0, -1])
def test_withdraw_invalid(rich_wallet, bad_amount):
with pytest.raises(ValueError, match="必须大于 0"):
rich_wallet.withdraw(bad_amount)
# ---------- can_afford ----------
@pytest.mark.parametrize("price, ok", [
(500, True), # 低于余额
(1000, True), # 等于余额
(1001, False), # 高于余额
(0, True), # 0 块也买得起(边界)
])
def test_can_afford(rich_wallet, price, ok):
assert rich_wallet.can_afford(price) is ok
# ---------- save / load 用 tmp_path ----------
def test_save_and_load_roundtrip(rich_wallet, tmp_path):
file = tmp_path / "wallet.json"
rich_wallet.save(file)
assert file.exists()
data = json.loads(file.read_text(encoding="utf-8"))
assert data == {"balance": 1000}
loaded = Wallet.load(file)
assert loaded.balance == 1000
def test_save_overwrite(rich_wallet, tmp_path):
file = tmp_path / "wallet.json"
rich_wallet.save(file)
rich_wallet.deposit(500)
rich_wallet.save(file)
loaded = Wallet.load(file)
assert loaded.balance == 1500
跑一下:
输出(节选):
test_wallet.py::test_default_balance PASSED
test_wallet.py::test_init_with_money PASSED
test_wallet.py::test_negative_initial_raises PASSED
test_wallet.py::test_deposit[100-1100] PASSED
test_wallet.py::test_deposit[0.5-1000.5] PASSED
test_wallet.py::test_deposit[1-1001] PASSED
test_wallet.py::test_deposit_invalid[0] PASSED
test_wallet.py::test_deposit_invalid[-1] PASSED
test_wallet.py::test_deposit_invalid[-100] PASSED
test_wallet.py::test_withdraw_normal PASSED
test_wallet.py::test_withdraw_too_much PASSED
test_wallet.py::test_withdraw_invalid[0] PASSED
test_wallet.py::test_withdraw_invalid[-1] PASSED
test_wallet.py::test_can_afford[500-True] PASSED
test_wallet.py::test_can_afford[1000-True] PASSED
test_wallet.py::test_can_afford[1001-False] PASSED
test_wallet.py::test_can_afford[0-True] PASSED
test_wallet.py::test_save_and_load_roundtrip PASSED
test_wallet.py::test_save_overwrite PASSED
============================= 19 passed in 0.05s ==============================
各位数一下——一共 12 个测试函数,但是因为有参数化,实际跑了 19 个用例。19 个用例 0.05 秒跑完,覆盖了正常路径、边界条件、异常分支、文件 IO。
再加上覆盖率:
理想情况下应该达到 100%——所有方法、所有分支、所有异常路径都测到了。
这就是各位以后每写一个类、一个模块都该做的事——功能代码 + 测试代码像孪生兄弟一样一起出现,缺一不可。
几个常见踩坑¶
坑 1:测试函数互相影响¶
counter = 0
def test_a():
global counter
counter += 1
assert counter == 1
def test_b():
global counter
counter += 1
assert counter == 1 # 这里挂——counter 已经是 2
测试之间共享了全局状态。test_a 先跑改了 counter,test_b 看到的就不是干净状态了。
解决:测试要么用 fixture 准备状态、要么测函数本身别有副作用。不要用全局变量在测试间传东西。
坑 2:测试时间太长¶
测试越写越多,跑一次几分钟。开发人员就懒得跑了,最后测试形同虚设。
解决:
- 慢的测试加
@pytest.mark.slow,本地不跑,CI 才跑 - fixture scope 调高(数据库连接 session 级别)
- 用
pytest-xdist插件并行跑:pytest -n auto
坑 3:浮点数比较¶
浮点数本身就有精度误差,直接 == 比经常挂。
解决:用 pytest.approx:
approx 表示「近似相等」,可以指定相对误差或绝对误差。
坑 4:fixture 之间循环依赖¶
pytest 会直接报错:fixture 'a' is recursive。
解决:检查 fixture 依赖图,把循环打开。通常说明有一个 fixture 该被拆成两个独立的。
坑 5:用 print 调试结果看不到¶
各位 debug 时随手加了 print,跑 pytest 后发现啥也没看到。
原因:pytest 默认捕获所有 stdout,只在测试失败时才显示。
解决:加 -s 参数:
或者改用 pytest --capture=no,效果一样。
FAQ¶
Q1:测试要写在哪?跟源码一起吗?
主流是分开放——源码 src/,测试 tests/。这样打包发布时测试代码不会被打进去。
也有项目把测试挨着源码放,比如 mypackage/test_foo.py 紧挨着 mypackage/foo.py。各有各的好。新项目水哥推荐分开。
Q2:要不要把所有功能都测到?
不必。关键路径必须测、边界条件优先测、异常路径要测;纯 getter/setter 那种琐碎的代码可以不测;私有函数大多通过测公共接口间接覆盖。
Q3:mock 和 fixture 是一回事吗?
不是同一个东西,但经常一起用。
- fixture 是「准备测试需要的东西」(连接、用户、临时目录)
- mock 是「假装某个对象/函数」(不真的发请求,假装返回了某个值)
pytest 自带 monkeypatch(轻量 mock);想要更强大的 mock,用标准库的 unittest.mock,或第三方 pytest-mock 插件。
Q4:测试代码里有重复怎么办?
跟普通代码一样——抽函数、抽 fixture、用参数化。但测试代码可读性优先于 DRY。一个测试如果为了不重复写得让人看不懂,宁可让它重复点。读测试的人想一眼看清「这个测试在测啥」,不想去翻一堆 helper 函数。
Q5:测试该跑多快?
单元测试(不连数据库、不访问网络)应该是秒级——一个项目几百个单元测试,目标是几秒到十几秒跑完。集成测试可以慢一点,但建议放在单独的 marker(比如 @pytest.mark.integration)里,跟单元测试分开跑。
小结¶
各位走完这一章,应该已经能:
- 写一个
pytest测试:def test_xxx(): assert ... - 用
fixture把准备工作抽出来,用scope控制生命周期 - 用
tmp_path、monkeypatch、capsys这些内置 fixture 搞定文件、环境、输出 - 用
@pytest.mark.parametrize把同一逻辑的多组数据合并 - 用
with pytest.raises(...)测异常路径 - 用
@pytest.mark.skip、skipif、自定义 marker 给测试分类 - 用
pytest-cov看覆盖率,找出漏测的代码 - 用
conftest.py在多个测试文件之间共享 fixture - 在
pyproject.toml里集中配置pytest
pytest 的好处是每写一行代码,心里都更踏实——下次再改老代码,跑一下测试就知道有没有改坏。水哥当年写第一份完整测试套件的时候那种感觉,跟通了任督二脉一样:从「不敢动」变成「随便动」,因为绿色那一片测试就是你最忠诚的安全网。
回到开头那个问题——「我代码改一改没事吧?」
现在各位有答案了吗?跑一下测试就行。
下一章咱们讲讲怎么把这套东西接进 GitHub Actions——uv 管依赖、ruff 管风格、pytest 管测试,三件套配齐之后,提一个 PR 自动跑、自动报告、自动拒绝坏代码,2026 年的 Python 工程化才算真正闭环。