2398 字
约 7 分钟
14
S16-B05 · 单元测试入门:从"能跑"到"有价值"

测试写完第一次跑就全绿,之后每次改代码还是靠手点页面确认——这不是测试没写好,是这些测试本来就没在验证任何东西。判断一条测试值不值,只问一句:把被测代码改坏,它会不会红。会红才叫回归保护,不会红就是装饰。


1. 测试要提供三件事

要提供的东西 具体形式 缺失后的症状
回归保护 行为被改坏时自动变红 改一行代码先全量手测
设计反馈 难测 → 耦合暴露 构造方法里 new 出一堆客户端
文档 读测试就知道用法与边界 只能读实现反推语义

对应的无效测试长这样:

@Test
void getterWorks() {
    User u = new User("a", 1);
    assertEquals("a", u.getName());   // 只证明 Lombok 生成了方法
}

一个只测 getter/setter 的套件覆盖率可能很好看,但它对任何真实回归都不敏感。


2. AAA 结构与最小示例

Arrange(准备)→ Act(执行)→ Assert(断言),三段各自独立,一个测试只做一次 Act。

class DiscountServiceTest {
    @Test
    void vip_满三百_打八折() {
        // Arrange
        DiscountService svc = new DiscountService(new FixedClock("2026-01-01T00:00:00Z"));
        Order order = new Order(ROLE_VIP, new BigDecimal("300.00"));

        // Act
        BigDecimal payable = svc.payable(order);

        // Assert
        assertEquals(new BigDecimal("240.00"), payable);
    }
}
def test_vip_over_300_gets_20_percent_off():
    svc = DiscountService(clock=FrozenClock("2026-01-01T00:00:00Z"))  # Arrange
    payable = svc.payable(Order(role="vip", amount=Decimal("300.00")))  # Act
    assert payable == Decimal("240.00")                                # Assert

两处 Act 意味着这是两个测试;两处 Assert 针对不同行为,同样应该拆开——拆开后失败信息才能直接指出哪个行为坏了。


3. 断言该断言什么

断言对象 适用 反例
返回值 纯函数、计算类 ——
状态变化 有副作用的写操作 只看返回值,不查落库
交互次数 边界处(发消息、调第三方) 对内部私有方法做 verify
抛出的异常 校验与失败路径 只测成功路径

过度断言的代价最容易被忽略:

// 脆弱:重构一次实现就要改测试
verify(repo, times(3)).findById(any());
verify(logger).debug("cache miss for {}", "u1");

断言实现细节(内部调用顺序、日志文案、私有方法调用)会让每次重构都变成测试重写,于是团队开始顺手删测试。断言对外可见的契约,不追内部过程。


4. 四种测试替身与各自的误用

替身 行为 典型用途 误用
Stub 返回固定值 隔离外部依赖 用它模拟复杂业务逻辑
Mock 记录调用、可校验 验证边界交互 对每个协作者都 verify
Fake 有可用的轻量实现 内存版仓储 让它和真实实现行为不一致
Spy 包装真实对象记录调用 保留真实现再观察 对已 mock 的对象再套一层
class InMemoryUserRepo:           # Fake:行为可用,不是摆设
    def __init__(self):
        self._rows = {}

    def save(self, user):
        self._rows[user.id] = user

    def by_id(self, uid):
        return self._rows.get(uid)

选择顺序:能用 Fake 就不用 Mock。Fake 校验的是行为结果,Mock 校验的是调用方式;调用方式改起来频繁,行为改起来少。


5. 命名与组织:一个行为一个测试

void 空购物车_结算金额为零() {}
void 优惠券过期_不参与计算() {}
void 库存不足_抛出库存异常() {}

名字里写清三件事:条件、动作、预期。读测试列表就像读需求清单,执行失败时不必打开 IDE 就知道坏了哪条。

组织上按被测单元分文件,别按层级分(不建 test/service/impl/internal/xxx 这种目录)。测试和被测算同一个包名下,只测 public 行为,private 方法通过 public 入口间接覆盖。


6. 什么时候不该写单测

场景 更合适的手段
框架胶水(配置类、注解映射) 启动一次集成测试即可
纯配置与常量表 抽查或用校验脚本
只做转发的 Controller 集成测试或契约测试
一次性数据修复脚本 跑一次看结果,写完即弃
生成代码 不测生成物,测生成器

把力气花在分支多、逻辑密、回归代价高的地方。判断入口:这段代码改错了,多久能被发现?越晚,越值得写单测。


7. 可测性设计:三根硬骨头

不可测的写法 改法
方法内 new 依赖 构造注入,接口类型接收
LocalDateTime.now() 注入 Clock / now 函数
静态工具类持有可变状态 去掉静态状态,改为实例或参数传入
Random 直接调用 注入种子或随机源
单例缓存与全局配置 显式传入,或提供重置入口
class ReportService {
    private final Clock clock;
    private final MailClient mail;

    ReportService(Clock clock, MailClient mail) {   // 依赖从外面进来
        this.clock = clock; this.mail = mail;
    }
}

依赖注入不是为了框架好看,它让"时间、随机数、网络"这三样最容易带来不确定性的东西变成可替换参数。


8. 覆盖率的正确用法

覆盖率回答的是"哪些分支从没被执行过",它的正确用法是当警戒下限:

  • 看未覆盖列表,挑出高风险代码补用例;
  • 看分支覆盖率而非行覆盖率,if 只有一条路走过时行覆盖仍然很高;
  • 设下限(低于阈值构建失败),不设上限与目标(当 KPI 会催生无断言测试)。

按 JaCoCo / coverage.py 的默认口径核对阈值即可,不要追求数字本身。


9. 速度:反馈闭环决定用不用

单测的价值靠被频繁运行兑现。典型量级上,一个健康套件跑完在秒级,单条在毫秒级。

层级 内容 运行时机
单测 纯内存、无 IO 保存文件即跑
组件/集成 起容器、连真实依赖 提交前、CI
端到端 真实环境链路 合并后、定时
$ pytest -q -m "not integration"        # 本地只跑快的
$ pytest -q --durations=10              # 揪出拖慢套件的十条

一旦"按保存就全量跑"变得不可忍受,团队就会退回到不跑。套件速度是设计约束的一部分。


10. 参数化与边界值选取

边界值从"空、单、多、极值、非法"五个方向取:

import pytest

@pytest.mark.parametrize("amount,expected", [
    (None,        Decimal("0")),        # 非法/缺失
    (Decimal("0"), Decimal("0")),       # 空
    (Decimal("299.99"), Decimal("299.99")),  # 单(未过阈)
    (Decimal("300.00"), Decimal("240.00")),  # 极值(阈值点)
    (Decimal("100000"), Decimal("80000")),   # 多
])
def test_payable_boundaries(amount, expected):
    assert discount_service.payable(Order("vip", amount)) == expected

参数化不是省行数,是把"同一逻辑的不同输入"显式列出来,边界一眼可见。


11. 常见坑

现象 根因 处理
单独跑过,全量跑红 测试顺序依赖 每条测试自建数据,不依赖前一条
偶发失败 共享可变状态 每例新建对象,静态成员放 @BeforeEach 清理
慢且不稳 用 sleep 等异步 轮询带超时,或注入可控时钟/调度器
重构就大面积红 断言了实现细节 改断对外可见行为
测试数量涨、缺陷照旧 只覆盖 happy path 补失败路径与边界

12. 边界:什么该交给别的层

想验证的东西 不该用单测 该用
SQL 是否正确 内存替身测不出方言与索引 集成测试 + 真实库
事务与并发 单线程跑不出竞态 并发测试 / 故障注入
序列化与框架绑定 mock 掉就等于没测 契约测试
跨服务调用 双方都 mock 就谁都对 契约测试 / E2E

13. 一个带依赖的服务怎么测

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock PaymentClient payment;
    @Mock OrderRepo repo;
    @InjectMocks OrderService svc;

    @Test
    void 支付成功_订单状态改为已支付() {
        when(payment.charge(any())).thenReturn(new ChargeResult(true, "OK"));
        when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));

        Order paid = svc.pay(new Order("o1", new BigDecimal("10.00")));

        assertEquals(OrderStatus.PAID, paid.status());
        verify(payment).charge(any());     // 边界交互断言一次就够
    }
}
def test_pay_marks_order_paid(fake_repo, stub_payment):
    svc = OrderService(payment=stub_payment(ok=True), repo=fake_repo)

    paid = svc.pay(Order(id="o1", amount=Decimal("10.00")))

    assert paid.status == "PAID"
    assert fake_repo.by_id("o1").status == "PAID"   # 断言副作用

写完后做一次自检:把 if (ok) 的分支改成永远走 else,套件该红;把状态赋值删掉,套件该红。都红了,这套测试才真的在保护你。


14. 自测

  1. 怎样用一句话判断一条测试有没有价值?
  2. 一条测试里出现两次 Act,说明什么?
  3. verify(repo, times(3)).findById(...) 这类断言,为什么重构时会成为负担?
  4. Fake 与 Mock 在"校验什么"上的区别是什么?
  5. 覆盖率高但缺陷漏得多,最可能的三种原因是什么?
  6. 想让"当前时间"可控,依赖注入改的是哪一处?
S16-B05 · 单元测试入门:从"能跑"到"有价值"
https://www.clxhxhhr.top/posts/6064/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。