一个团队把领域逻辑放进实体,然后发现"实体测试写不动":构造一个 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. 自测
- "实体难测"的三种成因里,哪一种说明"充血越界"了?
- 为什么"非法状态无法构造"会让测试变简单?
- "测试构造器(fixture)"解决了什么?它让测试只写什么?
- "不变式"该怎么测?为什么它最容易被漏?
- "表驱动"的状态机测试为什么最划算?它让什么"一眼可见"?
- 判断"该不该为实体写测试"的那个问题是什么?