本章

高级篇 / 第 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 / assertFalseassert x / assert not x布尔判断
assertIn / assertNotInassert x in y成员关系
assertIsNone / assertIsNotNoneassert x is NoneNone 判断(比 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 库)
随机数✅ 固定 seedrandom.seed(42) 或注入 RNG 对象
纯函数(math、string)❌ 不 mockmock 标准库纯属自欺欺人
内部纯逻辑模块❌ 不 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 不能做日志?

维度printlogging
级别控制没有,全都会输出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 / du/d向上 / 向下切换调用栈层级
w(where)bt打印调用栈
p exprp打印表达式值,p type(x)、p x.__dict__
pp exprpp美化输出(长对象更易读)
attra列出对象的所有属性
args—打印当前函数的所有参数
set var val—修改变量,继续用新值运行(热修现场)
b file.py:12break在指定行设断点;b func_name 按函数名
cl / cclear/continue清空所有断点 / 继续
x / pxx交互式探索:x data[:5]、x len(items)
qquit退出调试器
# 一次典型的 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):不暂停程序只在命中时打一条日志,适合「只想知道有没有走到这里」。

疑难杂症排查工具箱

问题工具用法
段错误 / 进程被 killfaulthandlerfaulthandler.enable(),崩溃时自动打印所有线程的 Python 栈到 stderr,无需配置
死锁faulthandler.dump_traceback_later(30)30 秒后没结束就打印所有线程栈,自动发现卡死点
程序卡住 / CPU 100%py-spypy-spy top --pid 1234 实时采样,py-spy dump --pid 1234 抓一次栈;py-spy record -o flame.svg --pid 1234 生成火焰图
性能热点cProfile / py-spy / line_profilerpython -m cProfile -o out.prof script.py 后用 snakeviz out.prof 看火焰图
内存泄漏tracemalloc / objgraphtracemalloc.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 / pyremypy src
bandit安全漏洞扫描安全 linterbandit -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-commitgit commit 前(默认)格式化、lint、快速测试——必须秒级反馈
prepare-commit-msg提交信息编辑前拼装分支名/工单号到 commit message
commit-msg提交信息写完后校验 commit message 格式(Conventional Commits)
pre-pushgit push 前跑完整测试套件、构建产物检查
pre-merge-commitmerge 前禁止向 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 章

完整工程实践清单

把本章所有内容压缩成一份「从零到可交付」的检查清单:

  1. 项目初始化:uv venv → 写 pyproject.toml(元数据 + 依赖 + 工具配置)→ uv pip install -e ".[dev]"。
  2. 目录规范:src/ 布局,分层为 core / domain / adapters / presentation,依赖方向永远是「外向内」。
  3. 测试先行:新功能先写失败的测试(TDD),核心逻辑目标行覆盖 80%+,关键分支必须覆盖。
  4. fixture 分层:session 级放重资源,function 级放测试对象,autouse 做统一清理。
  5. 隔离外部依赖:mock HTTP/DB/时间;优先依赖注入而非 patch。
  6. 日志规范:getLogger(__name__) + dictConfig + 多 Handler + 轮转;异常用 exc_info / logger.exception。
  7. 可观测性:卡死有 faulthandler,性能有 py-spy,内存有 tracemalloc。
  8. 提交门禁:pre-commit install,格式化 + lint + 类型检查,全在 commit 前跑。
  9. CI 门禁:多 Python 版本矩阵 + 覆盖率阈值 + 安全扫描,全绿才允许合并。
  10. 依赖可复现:锁文件入库,.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 → 部署。

练习题

  1. 用 assert 写不变量:实现一个 BankAccount 类,要求:初始化余额非负;deposit 金额必须 > 0;withdraw 金额必须 > 0 且不超过余额;每次操作后用 assert 校验余额非负。故意用 python -O 运行一次,说明为什么 assert 不适合做业务校验。
    参考答案要点:assert 只用于开发期的内部不变量,业务校验用 if amount <= 0: raise ValueError("金额必须为正");用 python -O -c "import account; ..." 验证 assert 被删除后错误输入不再被拦截;结论:-O 下 assert 完全失效,且其中表达式不求值。
  2. 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。
  3. unittest 命名陷阱复现:故意写一个「类名不以 Test 开头」和「方法名不以 test 开头」的用例类,运行 python -m unittest discover,观察结果是「0 tests OK」还是报错;然后修正命名再验证。
    参考答案要点:unittest 默认只在继承 TestCase 的类里收集 test* 方法,命名不符就完全不被收集且不报错(危险:以为测试通过了)。修正方案:类名 TestBankAccount、方法名 test_deposit、文件名 test_account.py。pytest 里则更宽松(类不需要继承,文件默认 test_*.py)。
  4. 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 而非全局变量)。
  5. 参数化测试:写一个 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。
  6. 覆盖率实战:为第 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 看绿色/黄色格子定位。
  7. 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 而真实请求被发出。
  8. 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() 的废弃警告。
  9. 日志规范落地:为一个含数据库和 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() 手动触发后检查文件数。
  10. 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 修改的是当前帧的变量,栈上层看不到修改(这是调试热修的局限)。
  11. 用 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。
  12. 从零搭建工程骨架:用 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/ 报告。
  13. 综合实战:给第 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 作为快速开始示例。
Python 完全教程 · 第 22 章