测试左移的更深一层:用 DDD 战术设计反推测试边界

系列专栏:测试工程师每日一博 · 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 反模式"跨聚合强一致"。

修复路径:

  1. inventory_snapshots 表的所有权归单一上下文(库存);
  2. 订单上下文需要数据 → 通过库存的 API 拿(契约清晰,Pact 可测);
  3. 单元测试层面:每个上下文自带 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 交汇。

十一、原则三句话

  1. DDD 战术设计本身就是测试用例边界地图 --- 不会读 DDD 等于让架构师画图自己看不懂;
  2. 聚合根 = 一组测试 ,不变式 = 一条 assertThat ,事件 = 一份契约;
  3. 贫血模型是最隐蔽的反模式 --- 它让测试金字塔底座从"承重墙"降级为"装饰墙"。

十二、思考题

留给今晚:

  1. 你团队最重要的聚合根是哪个?数一下它有几个不变式?测试覆盖了几个?
  2. 你团队的领域事件有 schema 测试吗?如果没有,最近一次因为事件字段飘导致下游挂是什么时候?
  3. 你团队的限界上下文边界图画过吗?如果画过,跟 Pact 测试集 (Day 9) 对得上吗?
  4. 看看 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 三件套。下篇见 👋

相关推荐
早睡早起身体好1231 小时前
为什么 Network 中会出现两个同名请求?——CORS 预检请求学习笔记
网络·笔记·学习·网络安全
casual~2 小时前
模逆元计算方法详解:扩展欧几里得算法与费马小定理
学习·算法·逆元
@Mike@2 小时前
06-数据库学习笔记(存储模型与数据压缩)
数据库·笔记·学习
我的xiaodoujiao3 小时前
快速学习Python基础知识详细图文教程14--模块
开发语言·python·学习·测试工具
wdfk_prog3 小时前
嵌入式面试真题学习笔记系列
笔记·学习·面试
懿路向前3 小时前
【HarmonyOS学习笔记】2026-07-30 | 小艺开放平台智能体接入实战
笔记·学习·harmonyos
吃好睡好便好3 小时前
MATLAB中图像的线性变换
开发语言·图像处理·学习·计算机视觉·matlab
YUS云生4 小时前
大模型学习·第41天:LangChain进阶——提示词模板与Chain链式调用
学习·langchain·c#
boppu4 小时前
酒店毛巾浴巾洗涤后手感评判标准
学习