测试与质量保证¶
一句话总结:测试金字塔从单元测试到端到端层层收敛;结合 pytest、mock、ML 专项校验、持续集成/持续部署 (Continuous Integration / Continuous Deployment, CI/CD) 与代码审查,把 bug 挡在生产之外。
测试就是你知道自己代码能跑的方式,不只是现在能跑,而是每次改动后都能跑。这一节讲测试金字塔、用 pytest 写单元测试、mock 技术、ML 专属代码怎么测、CI/CD 流水线、静态检查与格式化,以及代码审查——这些都是在 bug 进入生产之前把它抓住的工程实践。
-
ML 代码是出了名的测试薄弱。"能训练就等于能跑"是这个圈子的普遍心态。结果就是各种隐蔽 bug:顺序打乱错误的数据加载器、符号写反的损失函数、悄悄丢掉 5% 数据的预处理步骤。这些 bug 不会让你的程序崩溃,它们只会让模型悄悄变差,你却要花几周去调试那些"本该更高"的指标。
-
测试不是额外负担,它恰恰是"快而不砸"的最快路径。
测试金字塔¶
-
测试按层次组织,从快而窄到慢而广:
-
单元测试 (unit tests) (底层):隔离测试单个函数与类。快 (毫秒级)、数量多 (几百到几千个)。"
normalise_image输出的值是不是都落在 [0, 1] 区间?" -
集成测试 (integration tests) (中层):测试各组件能否协同工作。稍慢 (秒级)。"数据加载器产出的批次格式,是不是模型期待的那种?"
-
端到端测试 (end-to-end tests) (顶层):测试从输入到输出的完整流水线。慢 (分钟级)。"
python train.py --config test.yaml能不能顺利跑完,并输出一个合法的 checkpoint?"
-
-
金字塔形状意味着:单元测试写很多,集成测试少一些,端到端测试只留一小撮。单元测试能抓住大多数 bug,而且秒级跑完;端到端测试能抓集成问题,但慢且脆。
用 pytest 写单元测试¶
- pytest 是 Python 事实上的标准测试框架。所谓一个测试,就是一个以
test_开头的函数,放在以test_开头的文件里:
# tests/test_utils.py
def test_normalise_image():
import numpy as np
image = np.array([0, 128, 255], dtype=np.uint8)
result = normalise_image(image, mean=128, std=128)
assert result.min() >= -1.0
assert result.max() <= 1.0
assert abs(result[1]) < 1e-6 # 128 用 mean=128 归一化后应约等于 0
def test_normalise_empty():
import numpy as np
image = np.array([], dtype=np.uint8)
result = normalise_image(image, mean=128, std=128)
assert len(result) == 0
pytest tests/ # 跑所有测试
pytest tests/test_utils.py # 只跑一个文件
pytest -v # 冗长输出
pytest -x # 遇到第一个失败就停
pytest -k "normalise" # 按名字模式匹配测试
pytest --tb=short # 更短的回溯信息
夹具¶
- 夹具 (fixtures) 为测试提供可复用的准备逻辑。不必在每个测试里都写一遍 setup,定义一次就够:
import pytest
@pytest.fixture
def sample_dataset():
"""构造一个小数据集用于测试。"""
return {
"inputs": torch.randn(10, 3, 32, 32),
"labels": torch.randint(0, 10, (10,))
}
@pytest.fixture
def trained_model():
"""加载一个小型预训练模型。"""
model = SmallModel()
model.load_state_dict(torch.load("tests/fixtures/small_model.pt"))
return model
def test_model_output_shape(trained_model, sample_dataset):
output = trained_model(sample_dataset["inputs"])
assert output.shape == (10, 10) # batch_size x num_classes
- 夹具有作用域 (scope):
scope="function"(默认,每个测试一份新的)、scope="module"(每个文件一次)、scope="session"(整个测试运行一次)。像加载模型这种昂贵的准备工作,用scope="session"。
参数化测试¶
- 同一个函数用多组输入测试,但不重复代码:
@pytest.mark.parametrize("input,expected", [
([1, 2, 3], 6),
([], 0),
([-1, 1], 0),
([1000000, 1000000], 2000000),
])
def test_sum(input, expected):
assert sum(input) == expected
Mock 与打桩¶
- Mock (mocking) 就是在测试时把真实依赖换成一个假的。这样你就能孤立地测某个函数,不需要真去连数据库、调 API 或占用 GPU (Graphics Processing Unit, GPU)。
from unittest.mock import patch, MagicMock
def test_training_logs_metrics():
mock_logger = MagicMock()
with patch("my_project.training.trainer.wandb") as mock_wandb:
trainer = Trainer(logger=mock_logger)
trainer.train_one_epoch()
# 验证 trainer 记录了指标
mock_logger.log.assert_called()
# 验证它记录了一个 loss 值
call_args = mock_logger.log.call_args
assert "loss" in call_args[1]
-
什么时候该 mock:外部服务 (API、数据库、云存储)、昂贵操作 (GPU 计算、大文件 I/O),以及非确定性行为 (随机数生成器、时间戳)。
-
什么时候不该 mock:你自己的代码。如果你把一切都 mock 掉,你的测试就只是在验证"mock 的行为符合预期",而不是"你的代码能工作"。Mock 要打在边界上,你自己的逻辑要直接测。
测试 ML 代码¶
- ML 代码有独特的测试挑战:输出是概率性的、训练很慢、"正确"很模糊。
确定性种子¶
- 到处设随机种子,让测试可复现:
import random
import numpy as np
import torch
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
数值容差¶
- 浮点比较必须带容差 (见第 13 章的 IEEE 754):
# 差的写法: 精确比较, 会被浮点误差搞挂
assert model_output == 0.5
# 好的写法: 近似比较
import numpy as np
assert np.isclose(model_output, 0.5, atol=1e-5)
# 张量版本
assert torch.allclose(output, expected, atol=1e-4)
ML 里到底该测什么¶
- 形状测试:验证输出维度是不是符合预期。
def test_model_output_shape():
model = MyModel(d_model=256, n_classes=10)
x = torch.randn(8, 32, 256) # batch=8, seq=32, dim=256
output = model(x)
assert output.shape == (8, 10)
- 梯度流动:验证可训练参数的梯度非零。
def test_gradients_flow():
model = MyModel()
x = torch.randn(4, 3, 32, 32)
y = torch.randint(0, 10, (4,))
output = model(x)
loss = F.cross_entropy(output, y)
loss.backward()
for name, param in model.named_parameters():
assert param.grad is not None, f"No gradient for {name}"
assert param.grad.abs().sum() > 0, f"Zero gradient for {name}"
- 单批次过拟合:模型应该能背下一个批次。如果连这都做不到,那就一定是有根本问题。
def test_overfit_one_batch():
model = MyModel()
optimiser = torch.optim.Adam(model.parameters(), lr=1e-3)
x, y = get_single_batch()
for _ in range(100):
loss = F.cross_entropy(model(x), y)
loss.backward()
optimiser.step()
optimiser.zero_grad()
assert loss.item() < 0.01, f"Cannot overfit one batch: loss={loss.item()}"
- 数据校验:验证数据加载能产出合法输出。
def test_dataset_basics():
dataset = MyDataset("tests/fixtures/small_data.csv")
assert len(dataset) > 0
x, y = dataset[0]
assert x.shape == (3, 224, 224)
assert 0 <= y < 10
assert not torch.isnan(x).any()
assert not torch.isinf(x).any()
- 确定性:同样的输入 + 同样的种子 → 同样的输出。
def test_determinism():
set_seed(42)
output1 = model(input_data)
set_seed(42)
output2 = model(input_data)
assert torch.allclose(output1, output2)
CI/CD 流水线¶
-
持续集成 (Continuous Integration, CI):每次 commit 或 PR 自动跑测试。测试失败,PR 就不能合并。这样能防止坏掉的代码进入
main。 -
GitHub Actions 示例 (
.github/workflows/ci.yml):
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -e ".[dev]"
- run: ruff check src/
- run: mypy src/
- run: pytest tests/ -v --tb=short
- Pre-commit 钩子:在每次 commit 之前 (本地) 跑检查,把问题挡在进入 CI 之前:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
静态检查与格式化¶
-
静态检查 (linting) 不需要运行代码就能抓 bug 和风格问题。格式化 (formatting) 则自动强制统一风格。
-
Ruff:一个飞快的 Python linter 兼 formatter (一个工具就顶掉了 flake8、isort 和 black):
- mypy: Python 静态类型检查器。在运行之前就抓类型错误:
mypy src/
# src/model.py:42: error: Argument 1 to "forward" has incompatible type "int"; expected "Tensor"
- 类型注解让代码自带文档,顺便抓 bug:
def train(
model: nn.Module,
dataloader: DataLoader,
optimiser: torch.optim.Optimizer,
num_epochs: int = 10,
) -> float:
"""训练模型并返回最终 loss。"""
...
代码审查最佳实践¶
-
对作者:
- 请求 review 之前,先自己 review 一遍 diff。明显的问题你自己就能抓到。
- PR 要小而聚焦。一个 PR 只解决一个关注点。
- 写清楚描述:做了什么、为什么做、怎么测。
- 每一条评论都要回 (哪怕只是"已改")。
-
对审查者:
- 好好说话。批评代码,不是批评人。"这里可以更清楚一点"胜过"这写得让人看不懂"。
- 区分阻塞问题 (bug、安全) 和建议 (风格、命名)。用标签:"nit:"、"suggestion:"、"blocking:"。
- 多问问题,少下命令。"如果这个 list 是空的会怎样?"比"处理一下空的情况"有用得多。
- 及时批。一个 PR 等好几天没人 review,既卡住作者,又逼大家攒大 PR (更难 review)。
📌 本节要点¶
- 测试金字塔:单元测试最多、集成测试其次、端到端测试最少。
- pytest 基础:以
test_开头的函数即测试,用夹具复用 setup,用参数化避免重复。 - Mock 边界原则:只 mock 外部依赖 (API、DB、GPU),不 mock 自己的业务代码。
- 数值容差:浮点比较用
np.isclose/torch.allclose,不用==。 - ML 六大必测:形状、梯度流动、单批次过拟合、数据校验、确定性、数值容差。
- CI/CD:每次 commit / PR 自动跑 lint、type check、测试,失败即阻断合并。
- Pre-commit 钩子:把 lint 与格式化前置到本地 commit,减少 CI 反馈延迟。
- 工具链: Ruff 做 lint + format, mypy 做静态类型检查,类型注解自带文档。
- 代码审查心法:作者自 review + 小 PR + 清晰描述;审查者友善、区分阻塞与建议、多问问题、及时批。
- 测试哲学:测试不是负担,是"快而不砸"的最快路径。