高级篇 / 第 22 章
第 22 章 · 测试、调试与工程化
本章导读:能跑通的代码只完成了一半,另一半是「改动之后还能确信它是对的」。本章按工程现场的推进顺序讲:先用 assert 写最小断言,再用 unittest 了解标准框架,重点掌握 pytest 的 fixture、参数化、标记与覆盖率,接着解决测试中最头疼的「外部依赖」——mock;然后是 日志(区分 print 与 logging 的工程差异、DictConfig、轮转)、调试(pdb、PyCharm 断点、faulthandler、py-spy 定位卡死),最后落到项目结构规范(src 布局、pyproject.toml、分层架构)与 pre-commit 质量门禁。学完你能建立一个可以交付团队的小型工程骨架。
为什么必须写测试
先看一个真实的代价模型。假设一个项目每天被改动 20 次,每次改动引入缺陷的概率是 2%,一个缺陷的平均修复时间是 30 分钟:
# 缺陷进入生产的期望成本(无自动化测试)
p_change = 20 # 每天改动次数
p_bug_per_change = 0.02 # 单次改动引入缺陷的概率
cost_per_bug = 0.5 # 小时(30 分钟)
days_per_year = 250
expected_cost_per_year = p_change * p_bug_per_change * cost_per_bug * days_per_year
print(f"无测试的年期望成本: {expected_cost_per_year:.0f} 小时")
# 输出:无测试的年期望成本: 50 小时
# 引入 1 小时/天的自动化测试投入,可把 p_bug_per_change 降到 0.002
p_bug_with_test = 0.002
net = expected_cost_per_year - days_per_year * 1 + \
p_change * (p_bug_per_change - p_bug_with_test) * cost_per_bug * days_per_year
print(f"减去测试投入后净成本: {net:.0f} 小时") # 输出:15 小时
要点测试的价值不只是「抓 bug」,更重要的是重构的底气、回归防线(改 A 不会坏 B)、文档属性(测试是最不会过期的文档)、设计压力(难测的代码通常设计也有问题)。Martin Fowler 的说法:「任何没有被测试覆盖的代码,都要么没被用到,要么有 bug。」
测试金字塔与测试分类
| 层次 | 数量占比 | 运行速度 | 覆盖内容 | 是否依赖外部 |
| 单元测试(unit) | 多(70%+) | 毫秒~秒 | 单个函数/类的分支与边界 | 否(全部 mock 掉) |
| 集成测试(integration) | 中(20%) | 秒级 | 模块间协作、数据库、真实 HTTP | 是(用测试库/容器) |
| 端到端(E2E) | 少(10%) | 分钟级 | 用户完整路径 | 是(真实环境) |
| 维度分类 | 说明 | 工程建议 |
| 黑盒 / 白盒 | 不关心内部实现 / 覆盖每行代码 | 以黑盒为主,白盒覆盖率作为参考指标而非目标 |
| 回归测试 | 每次改动后重跑已有测试 | 必做,是 CI 的核心价值 |
| 冒烟测试(smoke) | 只跑最关键的少数用例,快速判断「能不能启动」 | 放 CI 第一道关,1 分钟内出结果 |
| 契约测试 | 验证服务之间的接口约定 | 微服务架构常用 Pact |
| 属性测试(property-based) | 随机生成大量输入,只断言不变式(不变量) | 用 Hypothesis,能发现手写用例想不到的边界 |
| 负载 / 压力测试 | 测并发与容量 | 参考第 25 章部署内容 |
常见坑把「覆盖率 100%」当 KPI 是典型的错误导向。行覆盖率高只说明代码被执行过,不代表行为被验证过(写一个 assert False 也能让行覆盖变 100%)。合理目标:核心业务逻辑行覆盖 80%+,且必须包含关键分支。更有价值的是变异测试(mutation testing):故意改坏源码看测试能否发现。
assert:最轻量的断言
# assert 条件, 失败时的消息
assert 1 + 1 == 2
assert isinstance("abc", str), f"期望 str,实际 {type('abc')}"
# 类型断言:运行期检查,Python 里是内置语法但需第三方库 mypy 才真正生效
def add(a: int, b: int) -> int:
assert type(a) is int and type(b) is int, "参数必须是 int"
return a + b
# 前置条件(precondition)与后置条件(postcondition)
def withdraw(balance: float, amount: float) -> float:
assert balance >= 0, "余额不能为负"
assert amount > 0, "取款额必须为正"
new_balance = balance - amount
assert new_balance >= 0, "取款后余额不能为负" # 后置条件
return new_balance
# 关键:assert 不只在测试里用,也可以作为「运行期不变量」写在业务代码里
class Account:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self._balance = balance
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> "Account":
assert amount > 0, "存款金额必须为正"
self._balance += amount
return self # 链式调用
print(Account("alice").deposit(100).deposit(50).balance) # 150.0
常见坑assert 是一条语句,用 python -O(优化模式)或 PYTHONOPTIMIZE=1 运行时会被整条删除,包括它后面的表达式求值。所以:绝不要把有副作用的逻辑写在 assert 里(例如 assert process_file(path) 会导致优化模式下完全不处理文件)。同时,不要用 assert 做用户输入校验——校验失败应当显式抛 ValueError。
unittest:标准库框架
unittest 是 Python 标准库自带的测试框架,任何环境都能用,理解它的模型对读懂 pytest 很有帮助。核心概念:TestCase(测试用例类)、assertEqual 等断言方法、setUp/tearDown 生命周期、TestSuite(用例集合)、TestRunner(执行器)。
"""test_calculator.py —— 用 unittest 写一个计算器测试。"""
import unittest
from calculator import Calculator, divide
class TestCalculatorAdd(unittest.TestCase):
"""一个 TestCase 类里可以放多个测试方法。"""
def setUp(self):
"""每个测试方法执行前跑一次:准备干净的测试夹具(fixture)。"""
self.calc = Calculator()
def tearDown(self):
"""每个测试方法执行后跑一次:清理资源。"""
del self.calc
def test_add_two_positive(self):
self.assertEqual(self.calc.add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(self.calc.add(-2, -3), -5)
def test_add_zero(self):
self.assertEqual(self.calc.add(0, 0), 0)
def test_add_float(self):
self.assertAlmostEqual(self.calc.add(0.1, 0.2), 0.3, places=7)
class TestCalculatorDivide(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_divide_normal(self):
self.assertEqual(self.calc.divide(10, 2), 5.0)
def test_divide_by_zero_raises(self):
# 断言「必须抛出某异常」
with self.assertRaises(ValueError) as ctx:
self.calc.divide(1, 0)
self.assertIn("除数", str(ctx.exception)) # 进一步断言异常信息
def test_divide_by_one(self):
self.assertEqual(self.calc.divide(7, 1), 7.0)
class TestCalculatorEdge(unittest.TestCase):
"""边界与特殊输入:测试套件里最容易漏的地方。"""
def setUp(self):
self.calc = Calculator()
def test_divide_by_negative(self):
self.assertEqual(self.calc.divide(10, -2), -5.0)
def test_very_large_number(self):
self.assertEqual(self.calc.add(10 ** 30, 1), 10 ** 30 + 1)
def test_sub_assert_messages(self):
# 直接使用断言失败(不推荐,unittest 惯例用 assertXxx 拿更好的报错信息)
try:
self.assertEqual(self.calc.add(1, 1), 3)
except AssertionError as e:
print("捕获到断言错误:", e)
if __name__ == "__main__":
unittest.main(verbosity=2) # verbosity=2 打印每个用例名
被测代码(calculator.py):
"""calculator.py"""
class Calculator:
def add(self, a: float, b: float) -> float:
return a + b
def subtract(self, a: float, b: float) -> float:
return a - b
def multiply(self, a: float, b: float) -> float:
return a * b
def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("除数不能为零")
return a / b
常用断言方法
| 断言 | 等价写法 | 用途 |
assertEqual(a, b, msg) | assert a == b | 相等(提供差异报告) |
assertAlmostEqual(a, b, places=7) | abs(a-b) < 1e-7 | 浮点数比较(必备) |
assertTrue / assertFalse | assert x / assert not x | 布尔判断 |
assertIn / assertNotIn | assert x in y | 成员关系 |
assertIsNone / assertIsNotNone | assert x is None | None 判断(比 assertEqual 更语义化) |
assertIsInstance(obj, cls) | — | 类型判断 |
assertRaises(Exc) | pytest.raises | 断言抛出异常 |
assertCountEqual(a, b) | assert sorted(a) == sorted(b) | 忽略顺序比较集合 |
subTest(msg=...) | — | 在一次测试里批量跑多组输入 |
assertLogs(level="ERROR") | caplog(pytest) | 断言确实打了日志 |
要点浮点数不能直接用 assertEqual 比较:0.1 + 0.2 == 0.3 是 False(二进制浮点表示误差)。用 assertAlmostEqual 或 math.isclose(a, b, rel_tol=1e-9)。这是初学者最常见的「莫名失败」之一。
subTest:优雅的批量断言
import unittest
class TestBatch(unittest.TestCase):
def test_many_inputs(self):
cases = [
(1, 1, 2),
(2, 3, 5),
(0, 0, 0),
(-1, -1, -2),
(1.5, 2.5, 4.0),
]
for a, b, expected in cases:
with self.subTest(a=a, b=b):
self.assertEqual(a + b, expected)
# 全部通过才会走到这里;任一失败时,subTest 让其余继续跑完
运行方式
# 1) 直接运行文件
python -m unittest test_calculator.py -v
# 2) 按模块/类/方法精确运行
python -m unittest test_calculator.TestCalculatorDivide
python -m unittest test_calculator.TestCalculatorDivide.test_divide_by_zero_raises
# 3) 按模式发现(默认找 test*.py)
python -m unittest discover -s tests -p "test_*.py" -v
# 4) 跳过某些用例
@unittest.skip("尚未实现,临时跳过")
def test_future(self): ...
@unittest.skipIf(sys.platform == "win32", "Windows 下不支持")
def test_unix_only(self): ...
@expectedFailure
def test_known_bug(self): ...
# 跳过也要写明理由(也可写 # pragma: no cover)
常见坑测试文件名必须叫 test*.py 或 *_test.py 才能被默认发现;测试类必须继承 unittest.TestCase 且以 Test 开头;测试方法必须以 test 开头。漏掉任一条,框架会「静默地一个用例都不跑」还显示 OK,这是新手最困惑的场景。
pytest:现代测试的事实标准
pytest 的核心卖点:零配置发现(不用继承任何类,函数名以 test_ 开头即可)、断言重写(原生 assert 失败时给出详细变量对比)、fixture(强大的依赖注入与资源管理)、参数化(一份代码测多组数据)、插件生态(覆盖率、参数化、并行、Mock 全部内置)。
# 安装与最小配置
pip install pytest pytest-cov pytest-mock pytest-xdist
# pytest-mock 提供 mocker 夹具;pytest-xdist 提供 -n 自动并行
# pyproject.toml 或 pytest.ini
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-ra --strict-markers"
markers = [
"slow: 耗时较长的用例",
"db: 需要数据库的用例",
"network: 需要外网的用例",
]
filterwarnings = ["error"] # 警告视为错误,防止 DeprecationWarning 积累
pytest 基本写法(对照 unittest)
"""tests/test_calculator.py"""
import pytest
from calculator import Calculator, divide
@pytest.fixture
def calc():
"""最简 fixture:返回一个测试夹具对象,函数结束自动销毁。"""
return Calculator()
def test_add(calc):
assert calc.add(2, 3) == 5
def test_float_compare():
# pytest 的断言重写:失败时打印两边的中间计算过程
assert 0.1 + 0.2 == pytest.approx(0.3, rel=1e-9, abs=1e-12)
assert [1.0, 2.5] == pytest.approx([1.0, 2.5, 3.0], abs=0.6)
def test_dict_and_list_compare():
# 集合/字典/长列表断言失败时会打印差异图
assert {"a": 1, "b": 2} == {"a": 1, "b": 2}
assert [3, 1, 2] == [1, 2, 3]
def test_raises():
with pytest.raises(ValueError, match="除数不能为零"):
divide(1, 0)
# 注意 match 是正则搜索,不是精确匹配
def test_warns():
import warnings
with pytest.warns(DeprecationWarning):
warnings.warn("这个 API 要废弃了", DeprecationWarning, stacklevel=2)
def test_exception_has_value():
with pytest.raises(ValueError) as excinfo:
divide(1, 0)
assert "除数" in str(excinfo.value)
assert excinfo.type is ValueError
# 类也可以组织,且不需要继承任何基类
class TestCalculator:
@pytest.fixture(autouse=True) # autouse=True:该类内每个用例自动执行
def setup_env(self):
self.env = "test"
yield
self.env = None
def test_env(self):
assert self.env == "test"
fixture 详解:这是 pytest 的灵魂
fixture(夹具)=「提供测试依赖的可复用函数」。核心规则:测试函数的参数名与 fixture 函数名同名,就会自动注入该 fixture;fixture 之间也可以声明依赖,形成依赖图。
| 装饰器 | 含义 | 典型用途 |
| (无) | 普通 fixture | 准备单个测试对象 |
@pytest.fixture(scope="function") | 默认值,每个测试函数执行一次 | 绝大多数场景 |
@pytest.fixture(scope="class") | 每个测试类一次 | 较重的只读资源 |
@pytest.fixture(scope="module") | 每个测试文件一次 | 导入代价大的数据 |
@pytest.fixture(scope="package") | 整个包一次 | 共享昂贵初始化 |
@pytest.fixture(scope="session") | 整个测试会话一次 | 数据库容器、HTTP 服务、只读大文件 |
@pytest.fixture(autouse=True) | 自动应用到同目录/同模块所有测试 | 统一的日志捕获、DB 清理 |
@pytest.fixture(params=[...]) | 参数化 fixture | 同一测试跑多套配置 |
@pytest.fixture(name="db") | 给 fixture 改名 | 改名字不破坏已有测试 |
"""tests/conftest.py —— 存放共享 fixture,pytest 自动加载。"""
import logging
import sys
import time
import pytest
@pytest.fixture(scope="session")
def config():
"""会话级:整个测试进程只算一次。"""
return {"env": "test", "db_url": "sqlite:///:memory:", "timeout": 5}
@pytest.fixture(scope="session")
def expensive_resource(config):
"""会话级重资源 + yield 清理(等价于 setUp/tearDown,但更通用)。"""
print("启动资源…")
resource = {"conn": object(), "started_at": time.time()}
yield resource # ---- 测试在这里运行 ----
print("释放资源…") # ---- 相当于 tearDown ----
del resource
@pytest.fixture
def timer():
"""yield 前是 setup,yield 后是 teardown,可写多个清理步骤。"""
start = time.perf_counter()
yield start
elapsed = time.perf_counter() - start
print(f"用例耗时 {elapsed * 1000:.1f} ms")
assert elapsed < 1.0, f"用例耗时过长: {elapsed:.2f}s"
@pytest.fixture(autouse=True)
def reset_environment():
"""每个用例前后自动执行:隔离环境变量,防止用例互相污染。"""
saved = dict(os.environ) if (os := __import__("os")) else {}
yield
os.environ.clear()
os.environ.update(saved)
@pytest.fixture
def mock_client(mocker):
"""依赖 pytest-mock 的 mocker:自动在用例结束后回滚所有 mock。"""
client = mocker.MagicMock()
client.get.return_value = {"status": 200, "data": [1, 2, 3]}
return client
@pytest.fixture
def cleanup_registry():
"""注册式清理:资源由用例登记,退出时统一释放,顺序可控制。"""
created = []
yield created.append
for obj in reversed(created):
if hasattr(obj, "close"):
obj.close()
# 用例里通过参数名注入
def test_timeout_config(config):
assert config["timeout"] == 5
def test_resource_alive(expensive_resource):
assert "conn" in expensive_resource
def test_client(mock_client):
assert mock_client.get()["status"] == 200
技巧conftest.py 的查找规则是「从测试文件所在目录向上逐级合并」,所以可以在项目根放一个全局 conftest.py 管通用 fixture,在 tests/ 放一个管测试专用 fixture,在 tests/integration/ 再放一个专管集成测试的。fixture 的覆盖遵循「就近优先」。
参数化:一份代码测多组数据
import pytest
from calculator import Calculator
calc = Calculator()
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, -1, -2),
(1.5, 2.5, 4.0),
(2 ** 31, 1, 2 ** 31 + 1),
])
def test_add_parametrized(a, b, expected):
"""每个元组生成一个独立用例,ID 清晰可读。"""
assert calc.add(a, b) == expected
# 给参数起名字:失败报告里显示 "两数相加-3-5",比 "test_add_parametrized[a-b-expected]" 好读
@pytest.mark.parametrize("a, b, expected",
[(3, 5, 8), (10, -2, 8)],
ids=["两数相加-3-5", "正负相加-10-2"])
def test_add_with_ids(a, b, expected):
assert calc.add(a, b) == expected
# 多个参数组合(笛卡尔积)
@pytest.mark.parametrize("a", [0, 1, 2])
@pytest.mark.parametrize("b", [10, 20])
def test_combination(a, b):
assert calc.multiply(a, b) == a * b # 共 6 个用例
# 间接参数:用函数间接生成参数(适合复杂构造)
def build_edge_cases():
return {"零": (0, 0, 0), "极值": (2 ** 63, 0, 2 ** 63)}
@pytest.mark.parametrize("a, b, expected",
build_edge_cases().values(),
indirect=False)
def test_edge(a, b, expected):
assert calc.add(a, b) == expected
# 参数化 + fixture 组合
@pytest.fixture(params=[{"retries": 0}, {"retries": 3}], ids=["无重试", "重试三次"])
def retry_policy(request):
return request.param
@pytest.mark.parametrize("threshold", [0.5, 0.8, 0.95])
def test_policy(retry_policy, threshold):
assert 0 < retry_policy["retries"] <= 3
assert 0 < threshold <= 1
# 直接传多组复杂对象
@pytest.mark.parametrize("payload, expected_count", [
pytest.param({"items": []}, 0, id="空列表"),
pytest.param({"items": [1, 2, 3]}, 3, id="三个元素"),
pytest.param({"items": list(range(1000))}, 1000, id="一千个元素",
marks=pytest.mark.slow), # 给单个参数打标记
])
def test_payload(payload, expected_count):
assert len(payload["items"]) == expected_count
要点参数化的价值:把「测试数据」从「测试逻辑」中剥离。数据表一眼可见,新增用例只加一行。判断是否该参数化:同一段断言逻辑,输入不同 → 参数化;断言逻辑不同 → 写两个测试函数。
标记(marker)与命令行选择
import pytest
@pytest.mark.slow
def test_slow_case():
"""耗时 30 秒,默认 CI 里跳过。"""
@pytest.mark.skip(reason="依赖尚未上线的 v2 接口")
def test_pending():
...
@pytest.mark.skipif(__import__("sys").platform == "win32", reason="仅 Linux")
def test_linux_only():
...
@pytest.mark.xfail(reason="已知 bug #123", strict=True) # strict=True 要求必须失败,否则判为 XPASS 失败
def test_known_broken():
assert 1 == 2
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.integration
def test_marked(x):
...
# conftest.py 里加钩子动态跳过
def pytest_collection_modifyitems(items):
for item in items:
if "network" in item.keywords and not HAS_NETWORK:
item.add_marker(pytest.mark.skip(reason="离线环境"))
# 常用命令行选项
pytest # 收集并运行 tests/
pytest -v # 显示每个用例名
pytest -q # 精简输出
pytest -x # 第一个失败就停(快速反馈)
pytest -k "test_add and not slow" # 按名字筛选
pytest -m "not slow" # 按标记筛选
pytest -m "integration"
pytest tests/test_calculator.py::TestDivide::test_by_zero # 精确到单个用例
pytest --lf # 只跑上次失败的(last-failed)
pytest --ff # 只跑上次失败的 + 先失败的
pytest --tb=short # 简短 traceback(short/long/line/native/no)
pytest --tb=long -vv # 出问题时用
pytest -p no:cacheprovider # 禁用缓存,用于只读文件系统
pytest --collect-only # 只收集不运行,看看收集到了什么
pytest --durations=10 # 显示最慢的 10 个用例
pytest --maxfail=3
pytest -n 4 # pytest-xdist:4 进程并行
pytest -n auto # 按 CPU 核数
pytest --cov=myapp --cov-report=term-missing --cov-report=html
pytest --cov-fail-under=80 # 覆盖率不足 80% 直接失败(CI 门禁)
pytest --cov-branch # 分支覆盖率
常见坑「pytest 显示 0 passed」九成是因为文件/函数命名不符合规则(文件不是 test_*.py,或函数不叫 test_*,或类不以 Test 开头)。先用 pytest --collect-only 确认收集情况,再检查是否把测试写在了一个以 _test 结尾但没在配置里声明的文件名上。
覆盖率
# 安装与运行
pip install pytest-cov coverage
pytest --cov=mypackage --cov-report=term-missing
# 输出示例:
# Name Stmts Miss Cover Missing
# -----------------------------------------
# mypackage/calc.py 28 2 93% 45-46
# mypackage/io.py 54 9 83% 12, 30-37
# mypackage/__init__.py 0 0 100%
# -----------------------------------------
# TOTAL 82 11 87%
# 生成 HTML 报告并在浏览器打开
coverage html -d htmlcov && open htmlcov/index.html # macOS
xdg-open htmlcov/index.html # Linux
# 只统计业务代码,排除测试文件与入口脚本(.coveragerc / pyproject.toml)
[tool.coverage.run]
source = ["mypackage"]
omit = ["*/tests/*", "*/__main__.py", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
要点「Missing」列指出的具体行号就是补测试的优先级清单。经验:从 0% 提到 70% 很快,最后那 20% 通常要处理异常分支、边界值和防御性代码——这些恰恰是最容易出线上事故的地方。
mock:隔离外部依赖
mock(模拟对象)解决一个核心问题:单元测试不应依赖网络、数据库、时间、随机数这些不受控因素。unittest.mock 提供两种手段——stub(桩)返回预设结果,mock(模拟)同时记录调用并断言调用方式。
四种 mock 对象
| 类型 | 说明 | 属性访问 | 方法调用 | 用途 |
MagicMock | 万能默认对象 | 自动生成任意属性 | 记录调用 | 绝大多数场景 |
Mock | 严格版:未配置的属性访问会报错 | 需显式设置 | 记录调用 | 想暴露「误用了未定义属性」时 |
patch | 临时替换某个目标对象 | — | — | 替换函数、类、方法、模块属性 |
spec/spec_set | 限定 mock 只能有被模拟对象的属性 | 越界报 AttributeError | 签名校验 | 防「测试通过但代码写错」 |
autospec | 按真实对象的签名自动生成 | 受限 | 校验参数个数 | 最佳实践 |
基础用法
from unittest.mock import MagicMock, patch, call
# 1) 创建一个被测服务的依赖替身
mock_api = MagicMock()
mock_api.fetch_user.return_value = {"id": 1, "name": "alice"}
mock_api.fetch_user.side_effect = ConnectionError("网络超时")
result = mock_api.fetch_user(1)
print(result) # {'id': 1, 'name': 'alice'}
print(mock_api.fetch_user.call_count) # 1
print(mock_api.fetch_user.call_args) # call(1)
print(mock_api.fetch_user.call_args_list) # [call(1)]
mock_api.fetch_user.assert_called_once_with(1) # 断言调用方式
mock_api.fetch_user.assert_called() # 至少调用过
mock_api.fetch_user.assert_not_called()
mock_api.reset_mock()
# 2) side_effect:让 mock 按序列返回不同结果,模拟多次调用/异常
mock_api.fetch_user.side_effect = [
{"id": 1}, ConnectionError("boom"), KeyError("name")
]
for _ in range(3):
try:
mock_api.fetch_user(1)
except Exception as e:
print(type(e).__name__, e)
# 第一次返回字典,后两次抛出对应异常
# 3) mock 与 pytest fixture 天然结合
import pytest
@pytest.fixture
def api():
m = MagicMock()
m.fetch_user.return_value = {"id": 1, "name": "alice"}
m.fetch_order.return_value = []
return m
def test_user_service(api):
svc = UserService(api)
user = svc.get_user(1)
assert user["name"] == "alice"
api.fetch_user.assert_called_once_with(1)
patch:在正确的位置打桩
import datetime
from unittest.mock import patch, ANY
# 语法:patch("导入路径.被替换的名字")
# 关键:patch 的是「代码查找该名字的位置」,不是「名字的定义处」
# 正确:patch 使用它的那个模块
with patch("myapp.report.datetime") as mock_dt:
mock_dt.date.today.return_value = datetime.date(2024, 5, 1)
result = build_report()
assert "2024-05-01" in result
# 错误:patch 定义它的那个模块(stdlib)—— 不会影响 myapp.report
with patch("datetime.date") as wrong:
pass # myapp 里的 datetime 不受影响,测试必然失败
# 装饰器写法(unittest.mock.patch 作为装饰器,参数自动注入)
@patch("myapp.service.http_client.get")
@patch("myapp.service.time.sleep")
def test_with_multiple_patches(mock_sleep, mock_get):
# 注意:装饰器自下而上注入参数
mock_get.return_value = {"code": 200}
result = fetch_all()
assert result == {"code": 200}
mock_sleep.assert_called_once_with(1)
# patch.object 修补实例或类上的属性
with patch.object(MyClass, "method_name") as m:
...
with patch.object(instance, "attr", new=42):
...
# patch.dict 修补字典/环境变量
with patch.dict(os.environ, {"API_KEY": "test-key"}):
client = build_client()
assert client.api_key == "test-key"
# 常量替换与 ANY 通配符
with patch("myapp.config.TIMEOUT", 0.001):
...
mock_get.assert_called_with(ANY, timeout=ANY)
# 停止 patch(进入上下文前手动启动,适合深层代码)
p = patch("myapp.heavy.load_model")
mock_load = p.start()
try:
...
finally:
p.stop()
# 参数化 patch:批量替换同一个位置
@pytest.mark.parametrize("value, expected", [(1, True), (0, False), (None, False)])
@patch("myapp.rules.check")
def test_rule(check_mock, value, expected):
check_mock.return_value = value
assert apply_rule(10) is expected
常见坑 1patch 路径写错是 mock 最大的坑来源。记忆法:
patch("模块.名字") 从「使用它的地方」开始往回找模块路径。patch 成功但没生效时,用
print(mock_obj.call_count) 确认到底有没有被调用到。
常见坑 2mock 过多是坏味道。如果一个测试里有 5 个以上的 mock,说明被测单元与外部世界的耦合太紧,应该重新设计依赖注入的边界。
mock 的反面:什么时候不该 mock
| 场景 | 是否 mock | 原因 |
| 第三方 HTTP 客户端 | ✅ mock | 网络不稳定、慢、需凭证 |
| 数据库 | 单元测试 ✅ / 集成测试 ❌ | 单元测试 mock,集成测试用真实库(Docker 起一个) |
| 当前时间 | ✅ mock | 保证测试可重复(freezegun 库) |
| 随机数 | ✅ 固定 seed | random.seed(42) 或注入 RNG 对象 |
| 纯函数(math、string) | ❌ 不 mock | mock 标准库纯属自欺欺人 |
| 内部纯逻辑模块 | ❌ 不 mock | 白白丢失了集成验证 |
技巧比 mock 更好的两个方案:①依赖注入——把外部依赖作为函数参数传进来,测试时传个假的即可,代码也更清晰;②面向接口编程——定义 Protocol/ABC,让真实实现和测试实现可互换。测试的难点往往暴露了设计问题。
冻结时间:让测试可重复
# pip install freezegun
from freezegun import freeze_time
import datetime
from myapp.expiry import is_expired
@freeze_time("2024-01-01 00:00:00")
def test_not_expired_yet():
token = datetime.datetime(2023, 12, 31, 23, 0, 0)
assert is_expired(token, ttl_seconds=7200) is False
@freeze_time("2024-01-02 00:00:00")
def test_expired():
token = datetime.datetime(2023, 12, 31, 23, 0, 0)
assert is_expired(token, ttl_seconds=7200) is True
@freeze_time("2024-06-15 12:00:00")
def test_fixed_report():
assert build_daily_report().date == datetime.date(2024, 6, 15)
日志:print 的正确替代品
为什么 print 不能做日志?
| 维度 | print | logging |
| 级别控制 | 没有,全都会输出 | DEBUG/INFO/WARNING/ERROR/CRITICAL,运行期可调 |
| 目的地 | 只能 stdout | 控制台、文件、syslog、远程日志系统,多路同时 |
| 格式化 | 手工 f-string | 内置时间戳、级别、模块名、函数名、行号、异常堆栈 |
| 轮转与归档 | 无 | RotatingFileHandler / TimedRotatingFileHandler |
| 运行期开关 | 改代码 | 改配置即可,DEBUG 级别只在小范围开启 |
| 生产安全 | 调试信息泄漏到用户界面 | 可按级别隔离,生产关掉 DEBUG |
import logging
# 1) 最小可用配置(模块级)
logger = logging.getLogger(__name__) # 永远用 __name__,不要用 root logger
logger.setLevel(logging.DEBUG)
# 2) 命令行配置:python app.py --log-level=DEBUG
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
args = parser.parse_args()
logging.basicConfig(
level=args.log_level,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logging.debug("这条只在 DEBUG 级别出现")
logging.info("服务启动,监听端口 %d", 8080) # 用 %s 惰性格式化,不要 f-string
# 3) 日志级别与适用场景
# DEBUG : 开发排障,函数入参出参、SQL 语句、中间状态(生产关闭)
# INFO : 关键业务节点(启动、请求完成、定时任务执行)
# WARNING : 异常但可自动恢复(降级、重试成功、参数不合法被拦截)
# ERROR : 功能失败,需要人介入(请求失败、数据不一致)
# CRITICAL: 应用即将不可用(数据库连不上、磁盘满)
多 Handler 与 DictConfig
import logging
import logging.config
import sys
from pathlib import Path
# 生产推荐:dictConfig 统一管理,配置可从 YAML/JSON 加载
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
},
"verbose": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | "
"%(filename)s:%(lineno)d | %(funcName)s | %(message)s",
},
"json": { # 结构化日志:便于 ELK / Loki / CloudWatch 解析
"format": '{"ts":"%(asctime)s","level":"%(levelname)s",'
'"logger":"%(name)s","msg":"%(message)s"}',
},
},
"filters": {
"no_debug": {"()": "logging.Filter", "name": "app"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "simple",
"stream": sys.stdout,
},
"file_debug": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "verbose",
"filename": str(LOG_DIR / "app_debug.log"),
"maxBytes": 10 * 1024 * 1024, # 单文件 10 MB
"backupCount": 5, # 保留 5 个历史文件
"encoding": "utf-8",
},
"file_error": {
"class": "logging.handlers.RotatingFileHandler",
"level": "ERROR",
"formatter": "json",
"filename": str(LOG_DIR / "app_error.log"),
"maxBytes": 50 * 1024 * 1024,
"backupCount": 10,
"encoding": "utf-8",
},
},
"loggers": {
"": { # root:捕获第三方库的日志
"handlers": ["console"],
"level": "INFO",
},
"app": {
"handlers": ["console", "file_debug", "file_error"],
"level": "DEBUG",
"propagate": False, # 不再往上重复输出
},
"app.db": {
"handlers": ["file_error"],
"level": "WARNING",
"propagate": False,
},
"uvicorn": {
"handlers": ["console", "file_debug"],
"level": "INFO",
"propagate": False,
},
},
}
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("app.service")
logger.info("用户服务初始化完成")
logger.warning("缓存命中率偏低: %.1f%%", 43.2)
logger.error("数据库写入失败", exc_info=True) # exc_info=True 打印完整堆栈
# 单独调高/调低某个 logger 的级别
logging.getLogger("app.noisy_module").setLevel(logging.WARNING)
异常日志的正确写法
import logging
logger = logging.getLogger(__name__)
# ❌ 反例 1:吞掉堆栈
try:
process(data)
except Exception:
logger.error("处理失败") # 丢失了原因和位置
# ❌ 反例 2:把异常对象拼进消息(丢失堆栈)
except Exception as e:
logger.error(f"处理失败: {e}")
# ✅ 正例 1:在 except 块里用 exc_info=True(等价于 exc_info=sys.exc_info())
try:
process(data)
except ValueError:
logger.error("参数不合法,data=%r", data, exc_info=True)
except Exception:
logger.exception("未预期的错误") # exception() 本身就是 INFO 级 + exc_info=True
# ✅ 正例 2:自定义异常类型,携带结构化信息
class PaymentError(Exception):
def __init__(self, order_id: str, gateway: str, reason: str):
super().__init__(f"订单 {order_id} 在 {gateway} 支付失败: {reason}")
self.order_id = order_id
self.gateway = gateway
self.reason = reason
try:
charge(order_id, amount)
except PaymentError as e:
logger.error("支付失败 order=%s gateway=%s reason=%s",
e.order_id, e.gateway, e.reason, exc_info=True)
metrics.increment("payment.failed", tags={"gateway": e.gateway})
# ✅ 正例 3:用 raise ... from ... 保留异常链
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise ValidationError(f"第 {e.pos} 个字符处 JSON 非法") from e # 保留 __cause__
要点raise X from Y 是 3.0 引入的异常链语法,它让 traceback 同时显示「原始错误」和「转换后的语义错误」,排障效率极高。同时 PEP 8 要求:捕获异常后要么重新抛出,要么记录日志,绝不能静默 pass。
在 Flask / Django 中配置日志
# Flask:复用 werkzeug 的日志但换 handler
import logging
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler("logs/app.log", maxBytes=10_485_760,
backupCount=5, encoding="utf-8")
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s [%(module)s:%(lineno)d] %(message)s"))
app.logger.setLevel(logging.INFO)
app.logger.handlers.clear() # 清掉默认的 stderr handler
app.logger.addHandler(handler)
app.logger.propagate = False
# 请求钩子里自动记录每个请求(参考第 18 章)
@app.before_request
def log_request_start():
g.start = time.perf_counter()
app.logger.info("→ %s %s from %s", request.method, request.full_path, request.remote_addr)
@app.after_request
def log_request_end(resp):
dt = (time.perf_counter() - g.start) * 1000
level = logging.WARNING if resp.status_code >= 400 else logging.INFO
app.logger.log(level, "← %s %s -> %s (%.1f ms)",
request.method, request.path, resp.status_code, dt)
return resp
# 捕获未处理异常并返回 JSON,同时记录堆栈
@app.errorhandler(Exception)
def on_exception(e):
app.logger.exception("未处理异常: %s", e)
return {"error": "内部服务器错误", "trace_id": g.get("trace_id", "")}, 500
调试:pdb 与断点
pdb(Python debugger)是标准库自带的交互式调试器,能在任何地方暂停程序、查看变量、修改变量、单步执行。
# 三种进入方式
def process(items):
total = 0
for i, item in enumerate(items):
total += transform(item)
return total
process([1, 2, 3]) # 方式 1:代码里写死
# pdb.set_trace() # ← 取消注释
# 方式 2:postmortem —— 崩溃后自动进入现场(最实用的调试姿势)
python -m pdb myscript.py arg1
# 方式 3:pdbpp / ipdb —— 更好的 IPython 交互体验
pip install pdbpp ipdb
python -m ipdb myscript.py
pdb 核心命令速查
| 命令 | 别名 | 作用 |
l(list) | l | 显示当前位置附近源码;l 100,150 看指定行段 |
ll | — | 列出当前函数所有局部变量与值 |
n(next) | n | 执行下一行,不进入函数 |
s(step) | s | 执行下一行,若在函数中则进入 |
c(continue) | c | 继续运行到下一个断点或结束 |
r(return) | r | 运行到当前函数返回 |
u / d | u/d | 向上 / 向下切换调用栈层级 |
w(where) | bt | 打印调用栈 |
p expr | p | 打印表达式值,p type(x)、p x.__dict__ |
pp expr | pp | 美化输出(长对象更易读) |
attr | a | 列出对象的所有属性 |
args | — | 打印当前函数的所有参数 |
set var val | — | 修改变量,继续用新值运行(热修现场) |
b file.py:12 | break | 在指定行设断点;b func_name 按函数名 |
cl / c | clear/continue | 清空所有断点 / 继续 |
x / px | x | 交互式探索:x data[:5]、x len(items) |
q | quit | 退出调试器 |
# 一次典型的 pdb 调试流程(异常发生后 postmortem)
$ python -m pdb buggy.py
> ../buggy.py(12)process()
10 total = 0
11 for i, item in enumerate(items):
----> 12 total += transform(item)
13 return total
(pdb) l # 看上下文源码
(pdb) ll # 看局部变量:total = 0, items = [1, 'x', 3], i = 1, item = 'x'
(pdb) p type(item) # <class 'str'> ← 找到 bug:元素类型不一致
(pdb) p [type(x) for x in items]
[<class 'int'>, <class 'str'>, <class 'int'>]
(pdb) pp items
(pdb) w # 看是谁调用的
/tmp/buggy.py(25)<module>() -> process([1, 'x', 3])
(pdb) set item 5 # 热修:把坏值改掉,继续跑完验证假设
(pdb) c
# 条件断点:只在 i == 2 时停
(pdb) b buggy.py:12, i == 2
# 在测试用例里调试:
pytest --pdb # 失败时进入 pdb(最快的调试测试方式)
pytest --pdb -x # 第一个失败就停下
pytest --trace # 每个用例一开始就停在首行
常见坑① 循环里用 pdb.set_trace() 会在每一轮停下,用 b 行号, 条件 条件断点更高效。② 忘了从终端复制字符串时用 !表达式 前缀(!len(items) 直接执行并打印)。③ 在多线程/async 代码里 pdb 行为可能异常,可用 py-spy(见下节)替代。
断点调试与 IDE
相比 pdb,IDE 的图形化调试器(PyCharm、VS Code)提供:可视化调用栈、表达式求值窗口、内存查看器、条件断点与「方法断点/字段观察点」、协程/async 单步、热重载。工程建议:
- VS Code:装 Python 扩展 → F5 启动调试 → 行号左侧点击设断点 → 调试控制台用
Debug: Evaluate 求值。
- PyCharm:Run → Debug;Smart Step Into 只进入你自己的代码、跳过标准库,是排查第三方库问题的利器。
- 条件断点统一写法:
if self.user_id == 42:,避免在海量数据里被逐个打断。
- 日志断点(logpoint):不暂停程序只在命中时打一条日志,适合「只想知道有没有走到这里」。
疑难杂症排查工具箱
| 问题 | 工具 | 用法 |
| 段错误 / 进程被 kill | faulthandler | faulthandler.enable(),崩溃时自动打印所有线程的 Python 栈到 stderr,无需配置 |
| 死锁 | faulthandler.dump_traceback_later(30) | 30 秒后没结束就打印所有线程栈,自动发现卡死点 |
| 程序卡住 / CPU 100% | py-spy | py-spy top --pid 1234 实时采样,py-spy dump --pid 1234 抓一次栈;py-spy record -o flame.svg --pid 1234 生成火焰图 |
| 性能热点 | cProfile / py-spy / line_profiler | python -m cProfile -o out.prof script.py 后用 snakeviz out.prof 看火焰图 |
| 内存泄漏 | tracemalloc / objgraph | tracemalloc.start(); tracemalloc.take_snapshot() 比较两次快照差异 |
| 慢 SQL | 数据库 EXPLAIN / slow query log | 参考第 17 章 |
| 网络请求慢 | httpx 日志 / requests hook | 打开连接池与 DNS 耗时日志 |
| 诡异并发问题 | PYTHONASYNCIODEBUG=1 | 开启 asyncio 调试模式,检测「未 await 的协程」「慢回调」 |
# faulthandler:卡死自诊断
import faulthandler
import sys
faulthandler.enable(file=sys.stderr, all_threads=True)
faulthandler.dump_traceback_later(30, exit=True) # 30 秒没结束就打印并退出
run_something_that_may_hang()
faulthandler.cancel_dump_traceback_later()
# cProfile:找 CPU 热点
import cProfile
import pstats
pr = cProfile.Profile()
pr.enable()
heavy_workload()
pr.disable()
pr.dump_stats("profile.out")
stats = pstats.Stats("profile.out")
stats.sort_stats("cumulative").print_stats(20) # 按累计耗时排前 20
stats.sort_stats("tottime").print_stats(10) # 按自身耗时(真实计算量)排
# tracemalloc:定位内存泄漏
import tracemalloc
tracemalloc.start(25) # 保留 25 帧栈
snapshot1 = tracemalloc.take_snapshot()
create_objects_in_a_loop()
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:10]:
print(stat) # 增量最大的 10 个分配点
# py-spy:在不重启进程的前提下抓栈(生产事故救星)
# pip install py-spy
# py-spy dump --pid 12345
# py-spy top --pid 12345 --duration 30
# py-spy record --pid 12345 --rate 100 -o profile.svg
要点线上卡死时,py-spy dump --pid 是最有效的一招——它不要求你在代码里事先插任何日志,也不会中断进程(除非用 --nonblocking 的相反默认行为)。很多生产事故的排查成本,就靠这一条命令从「重启丢现场」降到「10 分钟定位」。
项目结构规范
目录布局:推荐 src 布局
myproject/
├── pyproject.toml # 项目元数据与工具配置(现代标准)
├── README.md
├── LICENSE
├── .gitignore
├── .pre-commit-config.yaml # 代码质量门禁
├── .env.example # 环境变量示例(不含密钥)
├── src/
│ └── myapp/ # 真正的包目录
│ ├── __init__.py
│ ├── main.py # 程序入口
│ ├── cli.py # 命令行界面
│ ├── config.py # 配置加载(pydantic-settings)
│ ├── core/ # 与业务无关的底层能力
│ │ ├── __init__.py
│ │ ├── logging.py # 日志配置(注意会 shadow 标准库,包内用相对导入)
│ │ ├── errors.py # 自定义异常
│ │ └── types.py # 类型别名与数据类
│ ├── domain/ # 业务领域模型:纯逻辑,不依赖框架
│ │ ├── __init__.py
│ │ ├── models.py # dataclass / pydantic 模型
│ │ └── services.py # 业务服务
│ ├── adapters/ # 外部世界的适配层(IO)
│ │ ├── __init__.py
│ │ ├── db.py
│ │ ├── http.py
│ │ └── repository.py
│ └── presentation/ # 展示层:CLI / Web / GUI
│ ├── __init__.py
│ ├── cli.py
│ └── web.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享 fixture
│ ├── unit/
│ │ └── test_services.py
│ ├── integration/
│ │ └── test_repository.py
│ └── fixtures/ # 测试数据文件
├── docs/
│ └── index.md
├── scripts/
│ └── bootstrap.sh
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
└── .github/
└── workflows/
└── ci.yml # CI 配置
要点为什么推荐 src/ 布局?普通布局(项目根直接放包)会让 python myproject/ 意外把源码目录加进 sys.path,导致「本地能 import、装成 wheel 后 import 不到」这类诡异问题。src/ 布局强制你走「安装后再使用」的正规流程,是 PyPA 推荐的标准做法。
命名约定
| 对象 | 约定 | 示例 |
| 模块 / 包 | 全小写,下划线连接;避免与标准库重名 | user_service.py,不要叫 types.py、logging.py(会遮蔽标准库) |
| 类 | 大驼峰(PascalCase) | UserService、PaymentError |
| 函数 / 方法 | 小写 + 下划线(snake_case) | def get_user_by_id() |
| 常量 | 全大写 | MAX_RETRY = 3、DEFAULT_TIMEOUT = 30 |
| 私有成员 | 前缀单下划线 | self._cache(约定不外部使用) |
| 强私有 | 前缀双下划线(name mangling) | self.__secret |
| 特殊钩子 | 前后双下划线 | __init__、__repr__、__enter__ |
| 测试文件 | test_*.py,类名 Test* | tests/unit/test_payment.py → class TestPayment |
| 布尔变量 | is_/has_/should_ 前缀 | is_active、has_permission |
| 集合/字典复数 | 复数名词 | users、user_index |
pyproject.toml:现代项目的单一配置源
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "myapp"
version = "0.3.1"
description = "一个示例项目"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Alice", email = "alice@example.com" }]
keywords = ["cli", "data", "visualization"]
classifiers = ["Programming Language :: Python :: 3.12"]
dependencies = [
"requests>=2.31",
"pydantic>=2.7",
"typer>=0.12",
"rich>=13.7",
"pandas>=2.2",
"matplotlib>=3.8",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"pytest-cov>=5.0",
"pytest-mock>=3.14",
"ruff>=0.4",
"mypy>=1.10",
"pre-commit>=3.7",
"freezegun>=1.5",
]
plotly = ["plotly>=5.22", "kaleido>=0.2"]
[project.scripts]
myapp = "myapp.cli:main" # 安装后可直接在命令行敲 myapp
[project.urls]
Homepage = "https://github.com/example/myapp"
Issues = "https://github.com/example/myapp/issues"
[tool.setuptools.packages.find]
where = ["src"]
# ---- 工具配置集中放在这里 ----
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4", "RET", "ARG"]
ignore = ["E501"] # 行长交给 formatter
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # 测试里允许 assert
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
disallow_untyped_defs = true
mypy_path = "src"
[[tool.mypy.overrides]]
module = ["pandas.*", "matplotlib.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers --cov=myapp --cov-report=term-missing"
markers = ["slow: 慢用例"]
[tool.coverage.run]
source = ["src/myapp"]
branch = true
要点pyproject.toml(PEP 518/621)取代了老旧的 setup.py + setup.cfg:项目元数据、依赖、构建后端、以及 ruff/mypy/pytest 的配置全部集中在一个文件。不要手写依赖列表——用 [project] 声明元数据,然后用 pip install -e ".[dev]" 可编辑安装;用 uv pip compile / pip-tools 从 pyproject.toml 生成锁定的 requirements.txt。
依赖管理与虚拟环境
# 1) 创建虚拟环境(Python 3.12 自带的 venv)
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 激活后提示符前会出现 (.venv)
# 2) 安装依赖
pip install -U pip
pip install -e ".[dev]" # 可编辑安装 + 开发依赖
pip install -e . --no-deps # 只装自身,不装依赖
# 3) 导出依赖(可复现的关键)
pip freeze > requirements.lock # 完全锁定(含间接依赖)
pip list --format=freeze
# 4) 现代工具(更推荐)
pip install uv
uv venv --python 3.12
uv pip install -e ".[dev]"
uv pip compile pyproject.toml -o requirements.txt # 生成锁文件
uv pip sync requirements.txt # 严格按锁文件安装
uv run pytest # 自动用项目环境执行
# 5) .gitignore(必须包含)
# .venv/
# __pycache__/
# *.py[cod]
# .pytest_cache/
# .mypy_cache/
# .ruff_cache/
# htmlcov/
# .coverage
# dist/ build/ *.egg-info/
# .env
# secrets.json
代码规范工具链
| 工具 | 职责 | 替代了 | 调用方式 |
| ruff | 极速 linter + formatter(一个工具顶几百个) | flake8 + isort + black + pylint 的一大半 | ruff check . / ruff format . |
| black | 代码格式化(非争议风格) | — | black . |
| mypy | 静态类型检查 | pyright / pyre | mypy src |
| bandit | 安全漏洞扫描 | 安全 linter | bandit -r src |
| radon | 圈复杂度与可维护性指标 | — | radon cc src -s |
| vulture | 死代码检测 | — | vulture src |
| pylint | 综合静态检查(含设计建议) | — | pylint src(慢,CI 里跑) |
# ruff 常用命令
ruff check . # 检查全部规则
ruff check --fix . # 自动修复可修的
ruff check --select E,W,F,I,UP,B --ignore E501 .
ruff check --statistics # 统计各类问题数量
ruff format . # 格式化
ruff format --diff . # 只看格式差异不修改
ruff check --add-select S . # 额外启用 flake8-bandit 安全规则
# mypy 常用命令
mypy src --strict
mypy --disallow-any-explicit src
mypy --html-report html/mypy/index.html src
# 常见规则的关闭理由(放在文件顶部)
# ruff: noqa
# ruff: noqa: E501 整文件忽略行长
# type: ignore 忽略类型错误(尽量不用)
# mypy: disable-error-code="no-any-return"
# PEP 8 / PEP 20(Zen of Python)速记
# 4 空格缩进、79~100 字符行宽(ruff line-length)
# import 顺序:标准库 → 第三方 → 本地(ruff isort 自动)
# 一行不超过一个语句;函数要短(<50 行)、嵌套别太深(<5 层)
# 明确优于隐式;宁可清晰不要巧妙;先问对问题再写代码
pre-commit:把质量检查前置到提交时
pre-commit 是一个「提交钩子(git hook)」框架:每次 git commit 前自动运行一组检查,不通过就阻止提交。核心理念是「不要等到 CI 才发现问题」——CI 反馈要几分钟,而 pre-commit 只要几秒,作者当场就能改。
# 安装
pip install pre-commit
pre-commit install # 在 .git/hooks/ 写入 pre-commit 钩子
pre-commit install --hook-type commit-msg
pre-commit run --all-files # 手动对全部文件跑一次
pre-commit run --files src/app.py # 只跑指定文件
pre-commit autoupdate # 更新各钩子的版本
pre-commit clean # 清理缓存(缓存目录 ~/.cache/pre-commit)
.pre-commit-config.yaml 完整示例
minimum_pre_commit_version: "3.5.0"
# 所有钩子共享:失败一次就整体停止,输出更干净
default_stages: [pre-commit]
repos:
# ---------- 1. 基础卫生检查(最快,先跑) ----------
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace # 去掉行尾空格
- id: end-of-file-fixer # 确保文件以换行结尾
- id: mixed-line-ending # 统一换行符
args: [--fix=lf]
- id: check-yaml # 校验 YAML 语法
- id: check-toml
- id: check-json
- id: check-added-large-files # 禁止提交 >500KB 的文件
args: [--maxkb=500]
- id: check-merge-conflict
- id: check-case-conflict
- id: check-symlinks
- id: detect-private-key # 防止误提交私钥
- id: debug-statements # 禁止遗留 pdb / breakpoint()
- id: check-ast # 语法错误检查
# ---------- 2. 格式化(自动改写文件) ----------
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff-format # 等价于 black 的格式化
- id: ruff # 等价于 flake8 + isort + pyupgrade 的 lint
args: [--fix, --exit-non-zero-on-fix]
# ---------- 3. 静态类型检查(最慢,放最后) ----------
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
files: ^src/
additional_dependencies:
- types-requests
- pandas-stubs
args: [--strict, --ignore-missing-imports]
# ---------- 4. 自定义检查脚本 ----------
- repo: local
hooks:
- id: forbid-print
name: 禁止在 src/ 使用 print
entry: bash -c 'if grep -rn "print(" --include="*.py" src/; then echo "请改用 logging"; exit 1; fi'
language: system
pass_filenames: false
files: ^src/
- id: pytest-smoke
name: 冒烟测试(快速子集)
entry: pytest tests/unit -x -q --no-cov
language: system
pass_filenames: false
files: ^(src/|tests/)
stages: [pre-push] # 只在 push 前跑,不阻塞本地 commit
- id: changelog-check
name: 提交必须更新 CHANGELOG
entry: bash -c 'git diff --cached --name-only | grep -q CHANGELOG.md || echo "提示:本次改动建议更新 CHANGELOG.md"'
language: system
pass_filenames: false
stages: [commit-msg]
技巧钩子的执行顺序按 repos 列表顺序(同一文件内按钩子定义顺序),设计时要把「自动修复」类放在「检查」类之前——先格式化再 lint,才不会因为格式问题挡住真正的代码问题。另外 files 正则让钩子只对相关文件生效,能显著缩短耗时。
钩子的 stage 机制
| stage | 触发时机 | 建议用途 |
pre-commit | git commit 前(默认) | 格式化、lint、快速测试——必须秒级反馈 |
prepare-commit-msg | 提交信息编辑前 | 拼装分支名/工单号到 commit message |
commit-msg | 提交信息写完后 | 校验 commit message 格式(Conventional Commits) |
pre-push | git push 前 | 跑完整测试套件、构建产物检查 |
pre-merge-commit | merge 前 | 禁止向 main 直接合并 |
# commit message 规范校验(挂 commit-msg stage)
# 约定:<type>(<scope>): <subject>
# type ∈ feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert
- repo: https://github.com/alessandrojcm/commitlint-pre-commit
rev: v1.0.0
hooks:
- id: commitlint
stages: [commit-msg]
# commitlint.config.js
# module.exports = {
# extends: ['@commitlint/config-conventional'],
# rules: {
# 'type-enum': [2, 'always',
# ['feat','fix','docs','style','refactor','perf','test','build','ci','chore','revert']],
# 'subject-max-length': [2, 'always', 72],
# },
# };
常见坑① 首次运行 pre-commit 要为每个仓库创建隔离的虚拟环境并下载依赖,可能耗时几分钟且需要联网——CI 里要注意缓存(~/.cache/pre-commit)。② 钩子自动修改了文件会导致提交失败,正确做法是看完提示后 git add . 再提交,或用 git commit 直接触发第二轮自动通过。③ 别把耗时的完整测试放进 pre-commit 钩子,会让人养成 --no-verify 的坏习惯;把它放到 pre-push 或 CI。
持续集成:让质量门禁自动化
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check .
- run: ruff format --check .
- run: mypy src --strict
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- name: 运行测试(并行 + 覆盖率)
run: pytest -n auto --cov=myapp --cov-report=xml --cov-fail-under=80
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-${{ matrix.os }}
path: htmlcov/
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install bandit
- run: bandit -r src -ll
- uses: github/codeql-action/analyze@v3 # 代码扫描
| 门禁阶段 | 检查内容 | 失败后果 |
| 1. 静态检查 | ruff、mypy、bandit | 秒级,先跑最便宜的 |
| 2. 单元测试 | pytest + 覆盖率阈值 | 核心门禁 |
| 3. 构建 | python -m build + 安装验证 | 确保包真的能装能用 |
| 4. 集成/E2E | 带数据库/服务的完整流程 | 耗时最长,放最后 |
| 5. 部署 | 构建镜像、打 tag | 参考第 25 章 |
完整工程实践清单
把本章所有内容压缩成一份「从零到可交付」的检查清单:
- 项目初始化:
uv venv → 写 pyproject.toml(元数据 + 依赖 + 工具配置)→ uv pip install -e ".[dev]"。
- 目录规范:
src/ 布局,分层为 core / domain / adapters / presentation,依赖方向永远是「外向内」。
- 测试先行:新功能先写失败的测试(TDD),核心逻辑目标行覆盖 80%+,关键分支必须覆盖。
- fixture 分层:
session 级放重资源,function 级放测试对象,autouse 做统一清理。
- 隔离外部依赖:mock HTTP/DB/时间;优先依赖注入而非 patch。
- 日志规范:
getLogger(__name__) + dictConfig + 多 Handler + 轮转;异常用 exc_info / logger.exception。
- 可观测性:卡死有
faulthandler,性能有 py-spy,内存有 tracemalloc。
- 提交门禁:
pre-commit install,格式化 + lint + 类型检查,全在 commit 前跑。
- CI 门禁:多 Python 版本矩阵 + 覆盖率阈值 + 安全扫描,全绿才允许合并。
- 依赖可复现:锁文件入库,
.gitignore 覆盖 .venv/、__pycache__/、.env。
# 新项目一键初始化脚本(bootstrap.sh)
#!/usr/bin/env bash
set -euo pipefail
PROJECT="${1:?用法: ./bootstrap.sh [项目名]}"
mkdir -p "$PROJECT"/{src/${PROJECT//-/_}/core,tests/unit,docs,scripts}
cd "$PROJECT"
uv venv --python 3.12
uv pip install -e ".[dev]" 2>/dev/null || uv pip install pytest pytest-cov ruff mypy pre-commit
git init
pre-commit install
ruff init
echo "✅ 项目 $PROJECT 初始化完成"
本章小结
- 测试的价值在于重构底气、回归防线与设计反馈;覆盖率是参考指标不是 KPI,核心逻辑 80%+ 且覆盖关键分支才是目标。
- assert 是语句会被
python -O 删除,绝不能放副作用;用户输入校验要显式抛异常。
- unittest 需要继承
TestCase、方法以 test 开头、文件以 test*.py 命名,漏一条就「静默不跑」;浮点比较必须用 assertAlmostEqual。
- pytest:零配置发现 + 原生 assert + 断言重写 + 插件生态,是现代 Python 的默认选择。
- fixture 是 pytest 的灵魂:作用域(function/class/module/package/session)、
autouse、params、yield 清理;共享 fixture 放 conftest.py,就近优先覆盖。
- 参数化把数据从逻辑中剥离:
@pytest.mark.parametrize + ids + pytest.param;-k 按名筛选、-m 按标记筛选。
- 覆盖率用
--cov --cov-report=term-missing --cov-fail-under,Missing 列是补测的优先级清单。
- mock:
MagicMock / side_effect / assert_called_once_with;patch("模块.名字") 打在使用处;mock 过多是设计问题,优先依赖注入。
- 日志优于 print:级别控制、多目的地、内置格式化、轮转归档、运行期切换;
getLogger(__name__) + dictConfig + 惰性 %s + exc_info。
- 调试:
pdb.set_trace()、pytest --pdb(调试测试最快)、条件断点 b file:12, cond;卡死用 faulthandler,性能用 py-spy,内存用 tracemalloc。
- 项目结构:推荐
src/ 布局(避免 sys.path 污染),分层 core/domain/adapters/presentation,命名遵循 PEP 8。
- pyproject.toml 是唯一的项目配置源,元数据 + 依赖 + ruff/mypy/pytest 配置;虚拟环境 + 锁文件保证可复现。
- 工具链:ruff(lint + format,一个顶一百个)、mypy(strict)、bandit(安全)、radon(复杂度)。
- pre-commit 把检查前置到提交时:顺序是「卫生检查 → 自动格式化 → 静态检查」,耗时测试放
pre-push;不要滥用 --no-verify。
- CI 按成本排序设门禁:静态检查 → 单元测试+覆盖率阈值 → 构建 → 集成/E2E → 部署。
练习题
-
用 assert 写不变量:实现一个
BankAccount 类,要求:初始化余额非负;deposit 金额必须 > 0;withdraw 金额必须 > 0 且不超过余额;每次操作后用 assert 校验余额非负。故意用 python -O 运行一次,说明为什么 assert 不适合做业务校验。
参考答案要点:assert 只用于开发期的内部不变量,业务校验用 if amount <= 0: raise ValueError("金额必须为正");用 python -O -c "import account; ..." 验证 assert 被删除后错误输入不再被拦截;结论:-O 下 assert 完全失效,且其中表达式不求值。
-
unittest 改写:把第 1 题的
BankAccount 写一套 unittest 测试,至少覆盖:正常存取、余额不足取款、负数存款、零金额、连续存取后的最终余额、subTest 批量验证 5 组数值。要求用 assertRaises 校验异常并检查异常消息。
参考答案要点:class TestBankAccount(unittest.TestCase) + setUp 里 self.acc = BankAccount("alice", 1000);with self.assertRaises(ValueError) as ctx: self.acc.withdraw(2000) 后 self.assertIn("余额", str(ctx.exception));批量用 for amount, expect in cases: with self.subTest(amount=amount): ...;运行 python -m unittest tests.test_account -v。
-
unittest 命名陷阱复现:故意写一个「类名不以 Test 开头」和「方法名不以 test 开头」的用例类,运行
python -m unittest discover,观察结果是「0 tests OK」还是报错;然后修正命名再验证。
参考答案要点:unittest 默认只在继承 TestCase 的类里收集 test* 方法,命名不符就完全不被收集且不报错(危险:以为测试通过了)。修正方案:类名 TestBankAccount、方法名 test_deposit、文件名 test_account.py。pytest 里则更宽松(类不需要继承,文件默认 test_*.py)。
-
pytest fixture 体系:为上一题建立三层 fixture:
account(function 级,初始余额 1000)、bank(session 级,一个全局账户注册表)、timer(function 级,yield 前后打印耗时并断言 <1s),再加一个 reset_registry 的 autouse=True fixture 保证每个用例后注册表清空。至少写 5 个用例用上它们。
参考答案要点:@pytest.fixture def account() 返回对象;@pytest.fixture(scope="session") def bank() 创建全局注册表;@pytest.fixture def timer(): start=time.perf_counter(); yield; assert ...(teardown);@pytest.fixture(autouse=True) def reset_registry(bank): yield; bank.clear()(注意 autouse fixture 若要依赖 bank,bank 也必须是 fixture 而非全局变量)。
-
参数化测试:写一个
is_prime(n) 函数,用 @pytest.mark.parametrize 测 30 个数(含 0、1、负数、各种素数、合数),并用 ids 给出可读名称;再写一组「两个参数笛卡尔积」的用例(如 clamp(v, lo, hi) 的 27 组组合),并给其中 3 组打 @pytest.mark.slow 标记。命令行演示 -k、-m、-x、--tb=short 的效果。
参考答案要点:@pytest.mark.parametrize("n, expected", [(0, False), (2, True), ...], ids=["零", "二", ...]);多参数 @pytest.mark.parametrize("v", [-5, 0, 5]) + @pytest.mark.parametrize("lo", [0, 10]) 自动做笛卡尔积;给单组打标记用 pytest.param(..., marks=pytest.mark.slow);命令行 pytest -k "prime and not slow"、pytest -m "not slow" -x。
-
覆盖率实战:为第 1 题的代码库跑
pytest --cov=bankaccount --cov-report=term-missing,根据 Missing 列补齐测试直到行覆盖 100%,然后用 --cov-branch 查看分支覆盖,找出「行覆盖 100% 但分支没全覆盖」的典型例子。
参考答案要点:term-missing 给出未覆盖行号;常见「行覆盖有但分支缺」的情况是 if cond: return A 后没有 return B 分支、或 x if flag else y 只测了 flag=True;补测试时专门补这些反向分支;用 --cov-branch --cov-report=html 看绿色/黄色格子定位。
-
Mock 一个 HTTP 客户端:假设有个函数
fetch_user_profile(uid) 内部调用 requests.get("http://api/users/{uid}") 并解析 JSON。要求:① 用 patch 把 requests.get 换成 MagicMock;② 测 200 正常返回、404 抛自定义异常、超时抛 Timeout、返回非 JSON 抛 ValidationError;③ 断言调用参数(URL、超时时间、headers)。要求先用错路径(比如 patch requests 模块)体会一次失败,再改对。
参考答案要点:with patch("myapp.users.requests.get") as m: m.return_value.status_code=200; m.return_value.json.return_value={"id":1}; ...;用 side_effect=[响应1, 响应2] 测多次调用;m.assert_called_once_with("http://api/users/1", timeout=5, headers=ANY);错误路径用 side_effect=requests.Timeout();patch 错模块时表现为 m.call_count == 0 而真实请求被发出。
-
freeze_time 与可重复性:写一个
is_token_expired(token, issued_at, ttl) 函数(输入是 ISO 字符串),用 @freeze_time 固定时间后写 4 个用例:未过期、临界点(恰好等于 TTL)、已过期、跨天。验证「不使用 freeze_time 时同样的测试在不同日期会失败」。
参考答案要点:from freezegun import freeze_time;@freeze_time("2024-01-01 12:00:00") 装饰测试;临界点用例显式写 ttl=3600 与 issued_at 差正好 3600 秒,断言「过期」还是「未过期」取决于你定义的语义(< 还是 <=),测试要把这个约定固定下来;filterwarnings 检查 datetime.utcnow() 的废弃警告。
-
日志规范落地:为一个含数据库和 HTTP 的小服务写
logging_config.py(dictConfig):控制台 INFO + app.log(10MB 轮转、保留 5 份、verbose 格式含行号)+ error.log(JSON 格式、只收 ERROR)。然后写 test_logging.py 用 caplog 断言:某个函数在输入非法时确实产生了 WARNING 级别记录且消息包含关键字。
参考答案要点:caplog.set_level(logging.DEBUG, logger="app.service");assert "参数" in caplog.text;assert any(r.levelno == logging.WARNING for r in caplog.records);轮转配置 logging.handlers.RotatingFileHandler(maxBytes=10*1024*1024, backupCount=5);JSON 格式 {"format": '{"level":"%(levelname)s","msg":"%(message)s"}'};验证轮转可用 handler.doRollover() 手动触发后检查文件数。
-
pdb 与 pytest --pdb:写一个必然失败的测试(比如断言一个错误结果),用
pytest --pdb -x 停在现场,然后用 l / ll / p / w / u / d / x / set 这组命令把变量值看一遍,并用 set 把值改正确后 c 继续,让测试「被人为救活」。把这个过程记录成一段笔记。
参考答案要点:pytest --pdb 在失败时进入;ll 列局部变量、p obj/pp obj 求值、x items[:3] 交互探索、w 看栈、u/d 在栈层间切换、set total 99 热修后 c 继续;注意 set 修改的是当前帧的变量,栈上层看不到修改(这是调试热修的局限)。
-
用 py-spy 定位一个真实的性能问题:写一个「看起来很快但实际很慢」的脚本(例如在循环里反复做字符串拼接、或在嵌套循环里调
time.sleep(0.001)),先用 time.perf_counter() 计时确认它慢,再运行 py-spy record -o flame.svg -- python slow.py 生成火焰图(或 py-spy top 实时观察),定位热点后优化,测量优化前后的加速比。
参考答案要点:py-spy record --pid <pid> 抓正在运行的进程,--duration 10 限定采样时长,--rate 100 采样频率,输出 -o flame.svg 后浏览器打开;字符串拼接改为 "".join(parts);循环内的 sleep 用「攒够一批再统一处理」替代;加速比用两次 perf_counter 之比计算并写进 README。
-
从零搭建工程骨架:用
uv 或 venv 建一个新项目,写完整的 pyproject.toml(含 ruff/mypy/pytest/coverage 配置)、src/ 布局、tests/ 目录、.gitignore、.pre-commit-config.yaml,并让 pytest、ruff check、ruff format --check、mypy --strict 四条命令全部通过。然后写一个 GitHub Actions 工作流跑这四条。
参考答案要点:uv venv --python 3.12 && uv pip install -e ".[dev]";[build-system] 用 setuptools;[tool.setuptools.packages.find] where = ["src"];pre-commit 钩子顺序为 trailing-whitespace/end-of-file-fixer → ruff-format → ruff → mypy;CI 用 actions/setup-python@v5 + cache: pip,pytest --cov=myapp --cov-fail-under=80 作为合并门禁;actions/upload-artifact 上传 htmlcov/ 报告。
-
综合实战:给第 20 章的数据分析工具补上完整工程化:把第 20 章(或第 21 章)里写的可视化脚本改造成一个可交付的小工具:①
src/ 布局 + cli.py(argparse 或 typer,支持 --input/--output/--chart/--log-level);② 每个数据处理函数配 pytest 用例,用 fixture 加载 CSV,参数化测不同图表类型;③ HTTP 数据获取用 patch 打桩;④ 接入 dictConfig 日志与 RotatingFileHandler;⑤ 配 pre-commit 与 CI;⑥ 输出一份 README.md(安装、快速开始、参数说明、示例截图)。
参考答案要点:cli.py 用 @app.command()(typer)或 argparse.ArgumentParser,main() 作为 [project.scripts] 入口;测试用 @pytest.fixture 返回 tmp_path 构造临时 CSV 保证用例隔离;用 capsys 断言 CLI 输出;--cov-fail-under=70 作为起点;README 里用 pip install -e . 与 mytool --input data.csv --chart bar 作为快速开始示例。