测试不是「写完再补的体力活」,而是驱动设计的工具——先写一个失败的测试,再写刚好让它通过的代码。
你将学到
- 为什么值得写测试,测试到底在保护什么
unittest与pytest的对比与选择- pytest 核心用法:断言、
fixture、parametrize、mark、conftest.py - 用
mock/MonkeyPatch隔离外部依赖 - 用
pytest-cov看覆盖率,并理解它的局限 - TDD 红-绿-重构循环,并完整走一遍从零写函数的例子
前置知识
建议先读 上一篇:包管理与虚拟环境,本文命令默认在已激活的虚拟环境里执行。
一、为什么写测试
一句话:测试让你敢于改代码。没有测试的代码,改一行都心虚;有测试的代码,重构完跑一遍就知道有没有踩雷。它带来四件事:
- 回归保护:新功能不弄坏老功能。
- 设计压力:难测的代码往往耦合太紧,测试逼你把依赖解耦。
- 可执行文档:测试用例比注释更可信,因为它是会失败的。
- 调试成本:失败的测试定位问题,比在生产日志里翻快得多。
二、unittest vs pytest
unittest 是标准库自带的,JUnit 风格,写法冗长:
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(1 + 1, 2) # 必须用 assertXxx 方法,不能用裸 assert
pytest 是第三方框架,用原生 assert,更简洁、生态更强。
def test_add():
assert 1 + 1 == 2 # 直接 assert,失败时自动展开表达式
pip install pytest
pytest -v -x # 自动发现 test_*.py;显示用例;首个失败即停
pytest -k "add" # 按名字过滤
pytest 能直接跑 unittest 写的用例,所以迁移成本为零。新项目直接用 pytest。
三、pytest 核心用法
断言
import pytest
def test_exceptions():
with pytest.raises(ValueError, match="负数"): # 断言抛异常,match 匹配消息
raise ValueError("不能是负数")
def test_approx():
assert 0.1 + 0.2 == pytest.approx(0.3) # 浮点比较用 approx
pytest 会自动重写 assert,失败时打印每个子表达式的值,不用自己写提示信息。
fixture:测试的准备工作
import pytest
@pytest.fixture
def user():
# setup:这里造数据
return {"name": "Ada", "age": 36}
def test_name(user): # 参数名匹配 fixture 名,pytest 自动注入
assert user["name"] == "Ada"
@pytest.fixture
def db():
conn = open_connection() # 假装建连接
yield conn # yield 之前是 setup,之后是 teardown
conn.close() # 无论测试通过与否都会执行
def test_query(db):
assert db.is_open
yield 型 fixture 负责清理资源,比 setUp/tearDown 精确得多——只对用到它的测试生效。
作用域控制成本:@pytest.fixture(scope="session") 让 fixture 整个会话只建一次,适合昂贵的资源(数据库引擎、浏览器)。
parametrize:一个逻辑,多组数据
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 1, 2),
(2, 3, 5),
])
def test_add(a, b, expected):
assert a + b == expected
每个元组生成一个独立测试,失败时精确定位到哪组数据;比在函数里手写 for 循环好——后者一个失败就中断,前面通过的信息也没了。
mark:给测试分类
import pytest
@pytest.mark.slow
def test_heavy_computation():
...
def test_posix_only():
if sys.platform == "win32":
pytest.skip("仅 Linux") # 运行中动态跳过
...
pytest.mark.skip(reason=...) 静态跳过,pytest.skip() 运行时跳过,pytest.mark.skipif(条件) 按条件跳过。先在 pyproject.toml 注册自定义 mark,避免警告:
[tool.pytest.ini_options]
markers = ["slow: 慢速测试,默认可跳过"]
addopts = "-ra" # 显示简短失败摘要
conftest.py:跨文件的共享
放在目录里的 conftest.py,其中的 fixture 自动对该目录及其子目录可用,无需 import。
# tests/conftest.py
import pytest
@pytest.fixture
def sample_user():
return {"name": "Ada"}
# tests/test_a.py —— 直接用,不用 import
def test_one(sample_user):
assert sample_user["name"] == "Ada"
这是 pytest 最优雅的设计之一:共享 setup 而不污染生产代码。
四、mock 与 MonkeyPatch
测试要隔离外部世界:网络、时间、文件系统、第三方 API。
from unittest.mock import patch
def test_fetch_user():
# 把 requests.get 临时替换成假对象,返回固定响应
with patch("mymodule.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"id": 1}
assert fetch_user(1) == {"id": 1} # 不联网,结果确定
pytest 提供的 MonkeyPatch 更轻量,适合改属性/环境变量/字典项:
def test_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setattr("mymodule.TIMEOUT", 1)
# 测试结束后自动还原,不用手动清理
原则:只 mock 边界(网络、IO、时钟),不要 mock 你要测试的逻辑本身,否则测试只能证明「mock 被调用了」,毫无价值。
五、覆盖率 pytest-cov
pip install pytest-cov
pytest --cov=mypackage --cov-report=term-missing
# 输出示例:core.py 20 行语句,3 行未覆盖,85%,缺 14-16 行
term-missing 列出没被覆盖的行号,直接去补。别把覆盖率当 KPI:85% 里可能全是踩不到的边界,关键路径和错误分支远比总数字重要,合理目标是「核心逻辑覆盖充分」,不是 100%。
六、TDD:红-绿-重构
TDD 是一个三步循环:红(先写一个必然失败的测试,明确要什么行为)→ 绿(写最少的代码让它通过,不追求漂亮)→ 重构(测试是绿的,放心整理代码,然后回到红)。
完整示例:写一个「判断股票代码是否合法」的函数
需求:A 股代码是 6 位数字,以 600/601/603/000/002/300 开头。
第一步 · 红:先写测试。
# tests/test_validator.py
from mytool.validator import is_valid_stock_code
def test_normal_code():
assert is_valid_stock_code("600519") is True
def test_non_digit():
assert is_valid_stock_code("60051A") is False
此时跑 pytest,因为 validator.py 还不存在,必然报错——这就是红了。
第二步 · 绿:写最少代码通过。
# src/mytool/validator.py
VALID_PREFIXES = ("600", "601", "603", "000", "002", "300")
def is_valid_stock_code(code: str) -> bool:
if len(code) != 6 or not code.isdigit():
return False
return code.startswith(VALID_PREFIXES)
pytest tests/test_validator.py -v
# 输出: 2 passed
第三步 · 重构:测试绿了,现在可以放心改。补上参数化、加个边界用例。
# tests/test_validator.py(重构测试:用参数化收敛)
import pytest
from mytool.validator import is_valid_stock_code
@pytest.mark.parametrize("code, expected", [
("600519", True),
("300750", True),
("12345", False), # 位数不够
("60051A", False), # 含非数字
("400001", False), # 前缀不合法
])
def test_is_valid_stock_code(code, expected):
assert is_valid_stock_code(code) is expected
pytest -q
# 输出: 5 passed
注意整个过程:测试先行,代码只为满足测试而写。等你习惯了这个节奏,会发现写测试不是在增加负担,而是帮你把需求想清楚。
常见坑
坑 1:测试依赖执行顺序
# ❌ 用例之间共享可变状态,顺序一变就挂
shared = []
def test_a():
shared.append(1)
def test_b():
assert shared == [1] # 断言依赖 test_a 先跑
# ✅ 每个测试自备数据,用 fixture 隔离
import pytest
@pytest.fixture
def shared():
return []
def test_b(shared):
shared.append(1)
assert shared == [1]
坑 2:测试里写死真实网络请求
# ❌ 单元测试不该联网,慢且不稳定
def test_api():
assert get_user(1)["name"] == "Ada"
# ✅ 隔离边界,用 mock 提供确定性数据
from unittest.mock import patch
def test_api():
with patch("mymodule.requests.get") as m:
m.return_value.json.return_value = {"name": "Ada"}
assert get_user(1)["name"] == "Ada"
小结
- 测试的核心价值是让你敢于重构,而不是提升数字好看。
- 新项目用 pytest:原生
assert、fixture、parametrize、mark、conftest.py一套组合拳。 fixture用yield做 setup/teardown,作用域控制成本;conftest.py做跨文件共享。- 只 mock 外部边界,不要 mock 被测逻辑本身。
- 覆盖率看
term-missing补漏,但别当 KPI。 - TDD 的红-绿-重构循环:测试先行,逼你把需求想清楚。
延伸阅读
- pytest 官方文档的 fixture 与 parametrize 章节
- 《Test-Driven Development by Example》Kent Beck
pytest-cov与coverage.py的--cov-fail-under用法
上一篇:包管理与虚拟环境 · 下一篇:代码质量与工具链
文章回复
0 条公开回复