LabHub
学习 学习路径 课程

테스트 도구 실전 · 무엇을 테스트하나 · 讲解

커버리지는 목표가 아니라 지도다

在 LabHub 中继续学习

一句话总结

覆盖率是一张告诉你哪些地方尚未检查的地图。一旦把 100% 当作目标,它就不再是地图,而会变成必须填满的格子;从那一刻起,什么都不验证的测试就会越来越多。

概念图: 哪些地方尚未检查 · 什么都没有确认。 · 出错时的代价 · 缺失的行

为什么需要它

你可能见过这样的测试。

def test_create_user():    u = create_user("kim")    assert u is not None

这个测试执行了 create_user 的每一行,却什么都没有确认。覆盖率达到 100%,即使用户名没有保存,测试也会通过。

相反,真正危险的地方通常位于覆盖范围之外:错误处理分支、边界值、并发执行。这些地方因为编写测试麻烦而被跳过,也正因如此,缺陷会在其中生存。

优先测试什么

优先级应按照出错时的代价决定。

| 优先级 | 测试对象 | 原因 |
|---|---|---|
| 1 | 资金、权限、数据删除 | 出错后无法挽回 |
| 2 | 分支较多的纯函数 | 能以低成本验证大量情况 |
| 3 | 边界值和错误路径 | 人们很少主动尝试这些情况 |
| 4 | 一条集成路径 | 确认各组件能否连接起来 |
| 5 | 界面 | 成本最高,也最容易损坏 |

这正是测试金字塔所表达的原则——底部(单元测试)要宽,顶部(E2E)要窄。

如何阅读覆盖率

不要只看数字,要看缺失的行

pytest --cov=mymod --cov-report=term-missingName        Stmts   Miss  Cover   Missingmymod.py       42      6    86%   17-19, 28, 51-52

答案就在 Missing 中。如果 17–19 行是错误处理,就应该补上测试;如果 51–52 行只是日志,则可以不补。判断必须由人完成。

启用分支覆盖率后会更加准确。

pytest --cov=mymod --cov-branch

如果只走过 if x: 为真的路径,行覆盖率可能是 100%,分支覆盖率却只有 50%。

常见误解

“测试通过就说明程序正确”——测试只能证明缺陷存在,不能证明缺陷不存在。通过只代表“在我想到的情况里是正确的”,没有更多含义。

“慢测试也算测试”——测试太慢,人们就不会运行。不运行的测试比不存在的测试更糟(它会让人误以为系统已经被验证)。所有单元测试都应在几秒内结束。

选择测试对象的标准

不可能测试所有内容。应从两个维度判断——出错造成的损失出错概率

| | 经常变化 | 几乎不变 |
|---|---|---|
| 损失很大 | 必须测试,这是最高优先级 | 应测试,用于防止回归 |
| 损失很小 | 可以不测试 | 不测试 |

支付、认证、数据删除位于左上角;界面文案和日志格式位于右下角。为了提高覆盖率数字而填满右下角,只会增加维护成本,事故却不会减少。

测试金字塔及其反例

基本形态是把缓慢昂贵的测试放在上方,把快速便宜的测试放在下方。

      /\      E2E — 느리다(분), 잘 깨진다, 그러나 진짜를 본다     /      /----\    통합 — DB·큐를 실제로 띄운다(testcontainers)   /        /--------\  단위 — 밀리초, 로직만

不过,这种形态并非永远正确。对于逻辑很薄、集成很厚的服务(CRUD API、数据流水线),单元测试几乎没有价值。用模拟(mock)伪装数据库,反而无法发现真正的 SQL 错误。这类系统应当增加集成测试的比重。

比形态更重要的是“这个测试失败时,是否代表真实问题”。只要稍微修改实现就会失败的测试,会阻碍重构。这种测试比没有测试更糟。

好测试的三个特征

具有确定性。相同输入必须得到相同结果。依赖时间、随机数或执行顺序会造成间歇性失败,而间歇性失败很快就会被忽略。时间应通过参数注入(clock 参数),随机数应固定种子。

彼此独立。无论改变顺序,还是只运行其中一个,都必须通过。如果依赖前一个测试留下的数据,就无法并行执行。

名称能够表达意图。不要写 test_1,而应写 `test_returns_404_when_order_belongs_to_
another_user`。仅阅读失败列表,就应该知道哪里出了问题。

# 시각을 주입하면 결정적이 된다def is_expired(token, now=None):    now = now or datetime.now(timezone.utc)    return token.exp < nowdef test_expired_token_is_rejected():    t = Token(exp=datetime(2026, 1, 1, tzinfo=timezone.utc))    assert is_expired(t, now=datetime(2026, 1, 2, tzinfo=timezone.utc))

实务中真正重要的事

测试的价值取决于:失败时能否立即看出原因。

# 나쁨 — 왜 틀렸는지 모른다assert result == expected# 좋음 — 무엇이 다른지 보인다assert result.status == 200, f"응답: {result.status} {result.body[:200]}"

pytest 会自动显示 assert a == b 两侧的值。因此,与其编写很长的消息,不如把条件拆得更细。