系列专栏:测试工程师每日一博 · Day 21
上一篇:Day 20 · 测试工程师的 OKR 与度量:把"质量"翻译为可量化指标(第二季里程碑 🌟)
这是第二季第一篇正式开干。回到 Day 11 左移那条线 ------ 但更深一层。Day 11 讲"需求评审把缺陷消灭在编码前",Day 21 讲"用 DDD 战术设计把测试边界从架构里抽出来,在编码前就锁定"。
一、为什么 DDD 跟测试有关
工业里 DDD(Domain-Driven Design,领域驱动设计)常被当成"架构师的事"。测试工程师听到战术设计(聚合根 / 实体 / 值对象 / 领域事件 / 限界上下文)就觉得"跟我无关"。
这是个失误。测试跟 DDD 的关系是"基因级的"------下面四条对应:
| DDD 战术元素 | 它在测试里是什么 |
|---|---|
| 聚合根 (Aggregate Root) | 用例粒度的天然边界 --- 一个聚合根 = 一组测试 |
| 不变式 (Invariants) | 直接转 assertThat |
| 领域事件 (Domain Events) | 异步测试的契约 |
| 限界上下文 (Bounded Context) | 契约测试的分割线(回扣 Day 9) |
也就是说,DDD 战术设计本身就产出了一份高质量的测试用例骨架。不会读 DDD 的测试工程师,等于让架构师独自画了一份"什么该测、什么不用测"的地图,自己却看不懂。
这一篇就是教你看懂这张地图,并把它翻译成可执行的 case。
二、从一个真实场景:订单聚合根
具体一点。电商里最常见的"订单":
java
// 聚合根 Order(伪代码,JavaSpring 风格)
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderItem> items; // 实体集合(实体 / 值对象)
private OrderStatus status; // 枚举值对象
private Money totalAmount; // 值对象
private List<DomainEvent> events; // 领域事件收集器
// 不变式 1:订单必须至少一个 items
// 不变式 2:totalAmount = sum(item.subtotal)
// 不变式 3:状态机合法迁移 PENDING → PAID → SHIPPED → COMPLETED(可分叉 REFUNDED)
// 不变式 4:COMPLETED 后不可再 changeItems
// 工厂方法 --- 创建时强制建立不变式
public static Order create(CustomerId cid, List<OrderItem> items) {
if (items == null || items.isEmpty())
throw new InvalidOrderException("items can not be empty");
Order o = new Order(cid, items);
o.recalculate(); // 维持不变式 2
o.events.add(new OrderCreated(o.id, o.customerId, o.totalAmount));
return o;
}
public void pay(Money paid) {
if (status != OrderStatus.PENDING)
throw new InvalidStateTransitionException(status, PAID);
if (!paid.equals(totalAmount))
throw new PaymentMismatchException(paid, totalAmount);
status = PAID;
events.add(new OrderPaid(id, paid));
}
public void ship() { ... }
public void refund() { ... }
}
只看这一段 30 行 Java,测试工程师已经能数出至少 12 个值得测的边界。这就是 DDD 战术设计的"测试杠杆"------架构师写代码的时候,就把测试边界一并写好了。
三、聚合根 → 测试用例组的天然边界
3.1 为什么"聚合根 = 一组测试"
DDD 里聚合根最重要的约束是事务原子性 + 一致性边界:
- 聚合根内部:强一致(同一事务);
- 聚合根之间:最终一致(通过领域事件)。
这对测试工程师的意义是 :聚合根就是一个自包含的单元测试范围 。一个聚合根的所有不变式,可以用一组单元测试覆盖,不需要起 DB、不需要起其他服务。这是测试金字塔(Day 1)底座最高效的位置。
3.2 把聚合根翻译成测试结构
java
// 测试类跟聚合根一一对应
@DisplayName("Order 聚合根")
class OrderTest {
@Nested @DisplayName("工厂方法 create()")
class Create {
@Test void 正常路径_生成 OrderCreated 事件() { ... }
@Test void items 为空_抛 InvalidOrderException() { ... }
@Test void 首次创建_status 是 PENDING() { ... }
@Test void 首次创建_totalAmount 等于 items 小计() { ... }
}
@Nested @DisplayName("状态迁移 pay()")
class Pay {
@Test void PENDING + 正确金额_转为 PAID() { ... }
@Test void PENDING + 错误金额_抛 PaymentMismatchException() { ... }
@Test void 非 PENDING 状态_抛 InvalidStateTransitionException() { ... }
}
@Nested @DisplayName("状态迁移 ship() / refund()")
class ShipAndRefund { ... }
}
结构对应:
- 聚合根 = 顶层测试类;
- 每个聚合方法 = 一个
@Nested子类; - 每个不变式 = 至少一条
@Test; - 每个状态迁移 = 至少一条
@Test(正向 + 异常)。
这一套结构在工业里叫 Aggregate Test Pattern ,比"按方法分散写 case"结构性强 10 倍,新成员接手时一眼能看出测试覆盖了哪些不变式。
四、不变式 (Invariants) → 直接转 assertThat
不变式是 DDD 最值钱的产出。一个不变式对应一个"系统任何时候都要满足的真理"。
4.1 弱不变式 vs 强不变式
| 类型 | 例子 | 测试方式 |
|---|---|---|
| 弱不变式 | "amount ≥ 0" | 单测直接 assertThat,任何方法后都要成立 |
| 强不变式 | "totalAmount = sum(item.subtotal)" | 单测 + 变异测试(Day 3)必须杀虫 |
| 状态不变式 | "COMPLETED 后不可改 items" | 状态机测试参数化 |
4.2 不变式测试的模板
java
@Test
@DisplayName("强不变式:任意操作后,totalAmount 始终等于 items 小计")
void invariant_totalAmount_always_equals_sum_of_subtotals() {
// Given
Order order = Order.create(customerId, items(price=100, qty=2)); // total = 200
// When 对 order 做任意合法操作
order.pay(Money.of(200));
order.addItem(item(price=50, qty=1)); // 现在 total 应该 = 250
order.removeItem(firstItem()); // 现在 total 应该 = 50
// Then 强不变式仍然成立
assertThat(order.getTotalAmount())
.isEqualByComparingTo(sumOfSubtotals(order.getItems()));
}
// 一条不变式,几个状态变化后都成立 --- 这才是"真质量"
// 回扣 Day 17 §2.4 真断言密度
注意第二条:不变式测试是变异测试的好客户(Day 3 §5)。
python
# PIT mutation config 把这条不变式 case 注册为最强约束
pitest {
targetTests = ["OrderTest.invariant_*"] # 不变式 case 优先杀虫
mutators = ["STRONGER"] # 强变异算子
}
为什么要这样配?架构师写不变式时表达的就是"这是核心真理" ------如果变异测试在这个 case 上失败(变异没被杀),说明这条不变式没被代码尊重,这是 P0 bug 不是 P3。
五、领域事件 → 异步测试的契约
5.1 事件作为"出站契约"
DDD 限定上下文之间通过领域事件松耦合。一个事件等价于一份小契约(回扣 Day 9 的 Pact)。
java
// Order 聚合根发出的 OrderPaid 事件
public record OrderPaid(
OrderId orderId,
CustomerId customerId,
Money paid,
Instant occurredAt
) implements DomainEvent {}
测试工程师看这段,立即能写一份"事件 schema 测试":
java
@Test
@DisplayName("OrderPaid 事件 schema 稳定性 --- 等价于跨上下文契约")
void order_paid_event_schema_is_stable() {
OrderPaid event = trigger_pay();
// Schema 字段必须存在(Consumer 依赖)
assertThat(event).hasFieldOrPropertyWithValue("orderId", notNull());
assertThat(event).hasFieldOrPropertyWithValue("customerId", notNull());
assertThat(event).hasFieldOrPropertyWithValue("paid", notNull());
assertThat(event).hasFieldOrPropertyWithValue("occurredAt", notNull());
// 字段类型必须稳定(schema 版本管理)
assertThat(fieldType(event, "paid")).isEqualTo(Money.class);
assertThat(fieldType(event, "occurredAt")).isEqualTo(Instant.class);
}
这就是**"事件 schema 测试"**------它的价值等同于 Day 9 的 Pact consumer test,只是 Pact 跨进程,事件 schema 同进程。事件 schema 一变,这条 case 就红------开发立刻知道 consumer 会被打断。
5.2 事件的异步消费者测试
消费事件的下游服务,要做异步集成测试。回扣 Day 10 flaky 治理:
java
@Test
@DisplayName("OrderPaid 事件触发库存扣减 --- 用 Awaitility 不用 Thread.sleep")
void order_paid_event_triggers_inventory_deduction() {
// Given
OrderPaid event = OrderPaidFixture.create();
inventoryRepo.save(Item.stock("SKU_A", 100));
// When
eventBus.publish(event);
// Then --- 用 Awaitility(Day 10 §5)
await().atMost(5, SECONDS).untilAsserted(() ->
assertThat(inventoryRepo.findBy("SKU_A").getStock()).isEqualTo(99)
);
}
注意 Awaitility 替代 Thread.sleep ------ 这是 Day 10 §5 flaky 治理的核心动作。事件测试是 flaky 重灾区,不用 Awaitility 几乎必中招。
六、限界上下文 → 契约测试的天然分割
DDD 的限界上下文 (Bounded Context) 跟契约测试 (Day 9) 是天作之合:
┌─────────────────────────┐ ┌─────────────────────────┐
│ 订单上下文 │ 事件 → │ 库存上下文 │
│ Order 聚合根 │ ←━━━━━ │ Inventory 聚合根 │
│ 发布 OrderPaid 事件 │ HTTP → │ 暴露 /api/inventory │
│ │ │ │
└─────────────────────────┘ └─────────────────────────┘
consumer provider
(订阅事件) (被 HTTP 调用)
- 跨上下文的事件:用 §5.1 事件 schema 测试 + Pact(如果走消息中间件);
- 跨上下文的 HTTP :Pact 测试是该跑的(Day 9);
- 限界上下文内部:聚合根单元测试 §3。
也就是说,DDD 直接给出了契约测试的"边界地图" ------ 一个团队不知道契约测试该测哪些对,只要画出限界上下文图,每个边界都是一条契约。
6.1 一个失败的边界设计样本
工业里常见的反例:两个本该独立的上下文共享同一张数据库表 。订单服务和库存服务都被允许读写 inventory_snapshots 表 --- 表面看是"性能优化、避免 RPC",实际上毁掉了限界上下文的语义边界:
- 测试时无法独立起库存上下文(它没自带数据,得依赖订单库的状态);
- DDD 的"聚合根 = 一组测试"原则(§3)崩溃,Inventory 测试必须先建 Order 数据;
- Pact 契约测试(Day 9)失去意义 --- 没有"跨进程调用"也就没契约可言;
- 跨上下文强一致带来 §8 反模式"跨聚合强一致"。
修复路径:
- 把
inventory_snapshots表的所有权归单一上下文(库存); - 订单上下文需要数据 → 通过库存的 API 拿(契约清晰,Pact 可测);
- 单元测试层面:每个上下文自带 fixture(回扣 Day 14 数据治理)。
这就是 DDD 边界设计直接决定测试可写性的典型例子。架构师少画一个边界,测试工程师就得多写一倍集成测试 + 多背一倍 flaky(Day 10)。
yaml
# 让契约覆盖率(Day 20 §6.2)有清晰分母:
bounded_contexts:
- name: order
consumers: [inventory, shipping, payment]
events_published: [OrderCreated, OrderPaid, OrderShipped]
- name: inventory
consumers: [order] # 反向:库存告警 → 订单拦截
http_endpoints: [/api/inventory/{sku}]
这一份 YAML 跟 §5 的事件 schema 测试 + Day 9 的 Pact 自动对应,契约覆盖率(Day 20)的直接输入。
七、把 DDD 文档化:让测试工程师能读
实操层面的痛点:架构师画的 DDD 图往往只有他自己看得懂。测试工程师要能"翻译"它,需要团队统一一份事件风暴 (Event Storming) 产出物。
7.1 一份测试可读的事件风暴产出
markdown
# 领域:订单系统
## 聚合根
1. Order
- 不变式 INV-001: items 非空
- 不变式 INV-002: totalAmount = sum(subtotal)
- 不变式 INV-003: status ∈ {PENDING, PAID, SHIPPED, COMPLETED, REFUNDED}
- 不变式 INV-004: COMPLETED → items 不可改
## 事件
1. OrderCreated(orderId, customerId, items, totalAmount)
2. OrderPaid(orderId, paid)
3. OrderShipped(orderId, trackingNo)
4. OrderRefunded(orderId, refundAmount, reason)
## 命令(方法的入口)
- CreateOrder(customerId, items) → OrderCreated
- PayOrder(orderId, money) → OrderPaid (only when PENDING)
- ShipOrder(orderId) → OrderShipped (only when PAID)
## 限界上下文边界
- order ↔ inventory: OrderPaid 事件触发库存扣减(异步)
- order ↔ shipping: ShipOrder 命令调用 shipping 上下文(同步 HTTP)
测试工程师拿到这一份,立刻能产出 §3-§6 的所有测试代码 。每个"不变式"对应一条 assertThat,每个"命令"对应一个 @Nested 测试组,每个"事件"对应一份 schema 测试。
这就是"用 DDD 反推测试边界"的字面含义 --- 不是测试自己想边界,而是从领域设计里抽。
八、反模式:常见 DDD 误用导致测试失效
工业里常见的坑:
| 反模式 | 表现 | 测试后果 |
|---|---|---|
| 贫血模型 (Anemic Model) | Order 只有 getter/setter,业务逻辑在 Service | 不变式不能在聚合根测,只能 regional 测试 → 覆盖率虚高、质量虚低 |
| 聚合根过大 | 一个 Order 聚合包含了 Customer / Product / Shipping 全部 | 一组测试覆盖太大,flaky 高 |
| 事件无 schema | 自由 HashMap,字段飘 | 事件 schema 测试没法写,跨上下文不稳 |
| 跨聚合强一致 | Order 直接改 Inventory.stock 而非发事件 | 单元测试必须起 DB → 退化集成测试 |
| 没有命令边界 | Service 里大量"等等再加点"的逻辑 | 状态机测试无法定位"谁动了状态" |
其中"贫血模型"最隐蔽。Spring 项目里 80% 的 Order 其实是 POJO 而非聚合根。这一篇既是写给测试工程师,也是写给架构师 --- 贫血模型让测试金字塔底座垮掉。
九、跟测试金字塔的对位
回到 Day 1 测试金字塔,把 DDD 元素叠加进去:
┌─────────────┐
│ E2E │ ← 端到端业务流(跨多个上下文)
├─────────────┤
│ 集成测试 │ ← 限界上下文之间(契约测试 + 事件 schema)
├─────────────┤
│ 聚合根测试 │ ← 单聚合根不变式 ★(本文核心)
├─────────────┤
│ 单元测试 │ ← 值对象 / 实体 / 工具函数
└─────────────┘
聚合根测试是金字塔底座之上的"承重层"。它单元性(同进程、无外部依赖),但又覆盖业务规则(不变式 + 状态机)。是 Day 1 之外一直被忽略的中间层。
工业实践里,这一层做得好的团队,集成测试和 E2E 测试可以减半 ------ 因为业务规则在聚合根层已经压住,集成层只需要测"对接"。
十、回扣系列
| Day | 在 Day 21 它接到的线 |
|---|---|
| Day 1 测试金字塔 | §9 聚合根测试是金字塔的"承重层" |
| Day 2 Mockito | §3 不 mock 聚合根内部,只 mock 外部依赖 |
| Day 3 覆盖率 / 变异 | §4.2 不变式 case = 变异测试优先目标 |
| Day 4 Testcontainers | §6 限界上下文边界用容器跑集成(可选) |
| Day 9 契约测试 | §5.1 / §6 限界上下文 = 契约边界 |
| Day 10 flaky | §5.2 Awaitility 治理事件异步测试 |
| Day 11 测试左移 | §7 DDD 文档 = 左移产物 |
| Day 17 代码评审 | §2 评审聚合根时检查"贫血模型"反模式 |
| Day 20 OKR | 契约覆盖率(Day 20 §6.2)直接对接 §6 限界上下文 |
9 条对应。测试金字塔 + 契约 + flaky + 左移 + 评审 + OKR 这几条线在 Day 21 交汇。
十一、原则三句话
- DDD 战术设计本身就是测试用例边界地图 --- 不会读 DDD 等于让架构师画图自己看不懂;
- 聚合根 = 一组测试 ,不变式 = 一条 assertThat ,事件 = 一份契约;
- 贫血模型是最隐蔽的反模式 --- 它让测试金字塔底座从"承重墙"降级为"装饰墙"。
十二、思考题
留给今晚:
- 你团队最重要的聚合根是哪个?数一下它有几个不变式?测试覆盖了几个?
- 你团队的领域事件有 schema 测试吗?如果没有,最近一次因为事件字段飘导致下游挂是什么时候?
- 你团队的限界上下文边界图画过吗?如果画过,跟 Pact 测试集 (Day 9) 对得上吗?
- 看看 Order 类:它是聚合根还是贫血 POJO?如果是 POJO,业务逻辑分散在哪?(分散的程度 = 测试盲区的程度)
十三、TL;DR
- DDD 战术设计 = 测试用例骨架的自然来源;
- 聚合根测试 (§3) 是测试金字塔被忽视的承重层,做好它能让集成 + E2E 减半;
- 不变式 §4 直接转 assertThat,且必须是变异测试的优先目标;
- 事件 §5 = 等价于 Day 9 契约 (只是同进程);限界上下文 §6 = 契约分割线;
- 贫血模型 §8 是最隐蔽的 DDD 反模式,直接让测试金字塔底座残废;
- 9 条对位系列前文,Day 21 是"测试 + 架构咬合"的开始。
下一篇:Day 22 · Web UI 自动化的"自愈 1.5 时代":Shadow DOM、Web Components 与 selector 自愈
Day 13 §5 给过 selector 自愈的雏形,Day 22 把它推到 Web Components 时代 --- 主流前端框架(React/Vue/Angular)普遍用 Shadow DOM,传统 CSS selector 不再可靠。具备降级链 + LLM 自愈 + locator registry 三件套。下篇见 👋