Skip to content

测试、调试与代码质量 ​

学习目标 ​

  • 用断言和 pytest 编写清晰的小测试。
  • 区分单元测试、参数化、异常断言和 fixture。
  • 使用 traceback、logging、pdb 和 lint/format 工具缩短定位时间。

核心知识点 ​

测试验证可观察行为,不应只验证实现细节。单元测试快速隔离一个函数;fixture 管理共享准备;参数化覆盖多个输入。调试先复现,再看最小堆栈和局部状态,最后补回归测试。

常用用法 ​

assert ​

python
def add(left: int, right: int) -> int:
    return left + right


result = add(2, 3)
assert result == 5
print(result)
# 输出:5

简单脚本可用 assert 表达不变量;正式测试通常让 pytest 收集并报告失败上下文。

pytest 基础测试 ​

python
def add(left: int, right: int) -> int:
    return left + right


def test_add_returns_sum() -> None:
    assert add(2, 3) == 5


test_add_returns_sum()
print("test body passed")
# 输出:test body passed

文件通常命名为 test_*.py 或 *_test.py,测试函数以 test_ 开头。

powershell
python -m pytest -q
bash
python -m pytest -q

pytest 参数化 ​

python
cases = [(1, 2, 3), (0, 4, 4), (-1, 1, 0)]
results = [left + right == expected for left, right, expected in cases]
print(all(results))
# 输出:True

真实 pytest 写法使用 @pytest.mark.parametrize,每组输入都会显示为独立案例。

python
import pytest


@pytest.mark.parametrize(
    ("left", "right", "expected"),
    [(1, 2, 3), (0, 4, 4)],
)
def test_add_cases(left: int, right: int, expected: int) -> None:
    assert left + right == expected


print("parameterized test defined")
# 输出:parameterized test defined

pytest.raises ​

python
import pytest


def parse_port(text: str) -> int:
    value = int(text)
    if not 1 <= value <= 65535:
        raise ValueError("port out of range")
    return value


with pytest.raises(ValueError, match="range"):
    parse_port("70000")
print("error contract passed")
# 输出:error contract passed

异常测试应检查异常类型和必要的消息片段,不要把完整自然语言消息当成稳定 API。

fixture ​

python
import pytest


@pytest.fixture
def sample_user() -> dict[str, str]:
    return {"name": "Ada"}


def test_user_name(sample_user: dict[str, str]) -> None:
    assert sample_user["name"] == "Ada"


print(sample_user.__name__)
# 输出:sample_user

fixture 负责准备和清理,测试函数通过参数声明依赖;不要在多个测试中复制相同初始化代码。

logging 调试信息 ​

python
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
logger = logging.getLogger("demo")
logger.info("value=%s", 3)
print(logger.name)
# 输出:demo

日志参数用占位符传入,避免先拼接昂贵字符串;库代码不要随意调用 basicConfig。

breakpoint() ​

python
import sys

debugger_name = sys.breakpointhook.__name__
print(debugger_name)
# 输出:breakpointhook

交互调试时可在代码中临时放 breakpoint(),提交前应移除或由条件控制;生产排障优先使用日志和可观测性。

不常用但需要知道 ​

unittest.mock.Mock ​

python
from unittest.mock import Mock

service = Mock()
service.fetch.return_value = "ok"
result = service.fetch("id-1")
print(result, service.fetch.call_count)
# 输出:ok 1

mock 适合隔离慢或外部依赖,但过度 mock 会让测试只验证调用细节而失去行为价值。

unittest.mock.patch ​

python
from unittest.mock import patch

class Service:
    def fetch(self, key: str) -> str:
        return f"real:{key}"


service = Service()
with patch.object(service, "fetch", return_value="mocked"):
    value = service.fetch("id-1")
print(value)
# 输出:mocked

patch 应该作用于“被测模块查找依赖的路径”,而不是盲目 patch 依赖定义的原始路径。

traceback.format_exc() ​

python
import traceback

try:
    1 / 0
except ZeroDivisionError:
    summary = traceback.format_exc().splitlines()[-1]
print(summary)
# 输出:ZeroDivisionError: division by zero

记录完整 traceback 有助于定位;对用户返回时应隐藏内部路径和敏感数据。

compileall ​

python
import compileall

print(callable(compileall.compile_dir))
# 输出:True

可以用 python -m compileall 做语法级预检,但它不能替代类型检查、测试和运行时覆盖。

powershell
python -m compileall -q src tests

易混点 ​

  • 测试文件被 pytest 收集不代表测试真的覆盖了失败分支。
  • fixture 的作用域越大,状态泄漏风险越高;默认函数级最安全。
  • mock 的路径应是被测模块实际使用的名字。
  • breakpoint() 是调试工具,不应留在生产路径。
  • 格式化、lint、类型检查和测试解决不同问题,应组合使用。

课后小问 ​

  1. 为什么异常测试要同时检查类型和消息片段? 答案:类型确认错误分类,消息片段确认关键原因;完整消息可留给实现细节。 解析:只检查“抛了异常”可能误收错误类型,只匹配完整文本又容易因措辞变化产生脆弱测试。

  2. mock 外部服务时,为什么 patch 路径容易写错? 答案:应 patch 被测模块中查找依赖的名字,而不是依赖最初定义的模块路径。 解析:from client import fetch 后,被测模块使用的是自己的 fetch 绑定,patch 原模块不会替换它。

本节小结 ​

先写能复现行为的测试,再用最小日志和 traceback 定位;pytest 管理测试结构,mock 隔离边界,格式化/类型检查/编译预检共同提升质量。

快速回顾 ​

  • 基本断言:assert
  • pytest:收集、参数化、raises、fixture
  • 调试:logging、breakpoint、traceback
  • 隔离:Mock、patch
  • 预检:python -m compileall