2098 字
约 6 分钟
14
S18-A24 · 实体怎么测:为什么"纯内存"测试反而最难写

一个团队把领域逻辑放进实体,然后发现"实体测试写不动":构造一个 Order 要传 User、Price、Coupon 三个对象,而它们的构造又各自依赖更多东西。于是测试退化成"跑一遍 Service",而 Service 需要数据库。

"实体好测"是充血模型的卖点,而它有个前提:实体的依赖要"能被简单构造"。这个"前提"常被忽略:"充血"被当成"把逻辑放进去",而"可测"要求"放进去的东西不依赖外部"——两者不是同一件事,于是"充血了但测不动"很常见。
而"值对象 + 明确的前置条件"决定了这件事能不能成立。

1. "实体难测"的三种成因

成因 表现
构造参数太多 new Order(a,b,c,d,e)
依赖"服务/仓储" 实体内部查库
没有"合法状态"的入口 只能靠 setter 拼

第二项是"充血的常见越界":实体里出现 repository 或 HTTP 客户端第二项的"识别"很简单:看实体的构造参数里有没有"仓储/客户端"——有,就说明"它在做服务该做的事",而"测试"必然要"mock 一堆"。
——于是"纯内存测试"不可能。

2. "可测的实体"的三个特征

特征 说明
构造只依赖"值" 无服务、无 IO
状态只能通过"动作"改变 无 public setter
非法状态无法构造 构造器即校验

第三项的"额外收益"是"评审":"非法状态造不出来"意味着"代码里不可能出现脏数据"——于是"数据清洗脚本"这类东西变得不必要。
第三项是"测试友好"的隐藏来源:如果"非法状态"根本造不出来,测试就不必"造一堆异常场景"——它只需验证"动作的后果"。

3. "三种构造方式"的取舍

方式 测试成本
全参构造(5 个参数) 高(每个测试都要填)
工厂方法(Order.create(cmd)) 中(一句)
测试构造器(OrderFixture) 低(专门的测试工具)

第三项是"实践中最有效"的:**为测试写一个 fixture**第三项的"成本"是"多一个类",而它换来"每个测试少 15 行"——当实体有 10 个测试时,这笔账立刻回本。
(默认值 + 只改关心的字段)——于是"每个测试只写它关心的那一处"。

// 一个"只改关心字段"的测试构造器
Order o = OrderFixture.aOrder().withStatus(PAID).withAmount(120).build();
// 于是测试里"看不到"与它无关的 15 个字段

"夹具"要能"链式改",否则"仍然要写很多":

// 一个"够用"的夹具(默认值 + 只改关心的)
static Order aPaidOrder() {
    return OrderFixture.aOrder().withStatus(PAID).withAmount(120).build();
}
@Test void cancelPaidOrderIsRejected() {
    var o = aPaidOrder();
    assertThrows(IllegalStateException.class, o::cancel);
}
// 收益:测试正文只剩"动作 + 断言",一眼看出"在测什么"

4. "不变式"该怎么测

类型 测法
单动作后果 调用 → 断言状态
动作被拒绝 调用 → 断言异常
不变式保持 每个动作后 checkInvariant()

第三项的"实现"很轻:在每个"改状态方法"的最后调一次 checkInvariant()——于是"违反"在"运行时立刻炸",而不是"某天对账时发现"。
第三项最容易被漏:"金额 = 明细之和"这类不变式,应当在"每个改状态的动作后"都验证——而它需要"实体暴露一个可调用的检查方法"。

"转移表"可以直接当"测试用例生成器",于是"表"与"测试"是同一份数据:

// 一份"转移表"(表驱动 + 生成用例)
record T(Status from, Status to, boolean ok) {}
var table = List.of(
    new T(CREATED, PAID, true), new T(CREATED, SHIPPED, false),
    new T(PAID, SHIPPED, true), new T(PAID, CANCELLED, false));
table.forEach(c -> {
    var o = OrderFixture.aOrder().withStatus(c.from()).build();
    if (c.ok()) { o.moveTo(c.to()); assertEquals(c.to(), o.status()); }
    else assertThrows(IllegalStateException.class, () -> o.moveTo(c.to()));
});
// 收益:新增状态时"只改表",用例自动覆盖

5. "状态机"的三种测法

测法 覆盖
逐条断言(每个转移) 全(但用例多)
表驱动(合法/非法转移表) 全且紧凑
随机游走(fuzz) 发现"没想到的路径"

第二项是"最划算"的:把"转移矩阵"写成表第二项的"写法"是"一行一个转移":CREATED, PAID, ok / CREATED, SHIPPED, reject——于是"表里缺的行"就是"没测到的转移"。
——"CREATED→PAID 合法,CREATED→SHIPPED 非法"——于是"漏掉的转移"在表里一眼可见。

"依赖外部"的实体要"先拆再测",而拆的顺序决定"改动大小":

// 三步拆(从"最少改动"开始)
1 把"查库"改成"传入参数"(调用方查,实体算)—— 改动最小
2 把"外部调用"改成"领域事件"(实体发,外部收)—— 中等
3 把"整段逻辑"抽成"领域服务"(实体只留状态与不变式)—— 最大
// 判据:能否用"一步"让它"纯内存可测"

6. 排错:五种"实体测试问题"

现象 成因 处理方向
测试要起数据库 实体依赖仓储 把依赖移出实体
每个测试 20 行构造 无 fixture 加测试构造器
只能测"正常路径" 非法状态造不出 用"动作"触发拒绝
不变式没人测 无检查入口 暴露 checkInvariant
改了实现测试全红 测了内部结构 只断言"对外可见状态"

第五项的"修法"是"只断言"对外可见状态"":断言 order.status() 与 order.amount(),而不是"它内部调了几次 calculator"——后者是"实现细节"。
第五项是"测试脆"的根源:断言"内部字段"或"调用顺序",会让"重构"变成"改测试"——而它把"保护"变成了"阻碍"。

"构造"这件事值得单独说:"全参构造"是测试的敌人,而"命令对象"是它的朋友。

// 两种构造的测试成本差
new Order(user, items, price, coupon, addr, ts)      // 6 个参数,每个测试都要填
Order.create(new CreateOrderCmd(uid, skuId, qty))    // 1 个命令对象,默认值可复
// 判据:测试里"与断言无关的字段"越多 → 越该引入"命令对象/工厂/夹具"

7. 边界:什么情况不必为实体写测试

场景 为什么 做法
实体只是"数据容器" 无逻辑可测 不测
逻辑在"领域服务"里 测试对象是服务 测服务
已有"端到端"覆盖关键路径 成本更低 可选
一次性模型(原型) 不长期 不测

判断口径一句话:"这个实体里有没有"if / 计算 / 状态校验""。最后一条与"演进"有关的边界:"实体测试"是"重构的安全网"——当你要"把逻辑从 Service 搬进实体"时,先有实体测试,才敢搬;否则"搬家"就是"重写"。
有(真逻辑),就值得"纯内存测试";没有(只存字段),测它是"测 getter"。

补一个"验收"口径:实体测试应当"不启动任何容器、不连任何外部"——如果一个"实体测试"需要 Spring 上下文,那它测的其实是"集成"。这条口径很硬,而它正好能暴露"实体依赖了不该依赖的东西":跑得慢的实体测试,几乎总是"依赖没拆干净"的信号。

8. 自测

  1. "实体难测"的三种成因里,哪一种说明"充血越界"了?
  2. 为什么"非法状态无法构造"会让测试变简单?
  3. "测试构造器(fixture)"解决了什么?它让测试只写什么?
  4. "不变式"该怎么测?为什么它最容易被漏?
  5. "表驱动"的状态机测试为什么最划算?它让什么"一眼可见"?
  6. 判断"该不该为实体写测试"的那个问题是什么?
S18-A24 · 实体怎么测:为什么"纯内存"测试反而最难写
https://www.clxhxhhr.top/posts/6652/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。