领域测试套件
wow-test 用内存中的领域运行时执行聚合与无状态 Saga 规格。它适合验证命令决策、领域事件、事件溯源后的状态和 Saga 产生的命令,不需要数据库或消息中间件。
测试边界
领域规格通过只证明领域行为。它不证明 KSP 产物已打包、Spring 已正确装配、HTTP 路由可用、真实存储可恢复或鉴权有效。这些门禁见Wow 应用测试。
完成信号
每条已建模不变量都应至少有一个成功或拒绝规格;状态转换同时断言事件与溯源状态。领域层完成后,运行所属模块的 test/check,再进入应用集成门禁。
安装
dependencies {
testImplementation("me.ahoo.wow:wow-test:${wowVersion}")
}dependencies {
testImplementation "me.ahoo.wow:wow-test:${wowVersion}"
}<dependency>
<groupId>me.ahoo.wow</groupId>
<artifactId>wow-test</artifactId>
<version>${wow.version}</version>
<scope>test</scope>
</dependency>Kotlin 断言使用项目测试栈中的 FluentAssert 扩展:
import me.ahoo.test.asserts.assertGiven → When → Expect
| 阶段 | 要表达的问题 | 常用 DSL |
|---|---|---|
| Given | 聚合此前发生了什么? | givenEvent、givenState、givenOwnerId、givenSpaceId、inject |
| When | 现在执行什么? | whenCommand、Saga 的 whenEvent |
| Expect | 结果是否符合不变量? | expectNoError、expectErrorType、expectEventType、expectState、expectCommand |
优先用历史事件建立 Given。givenState 适合明确需要从某个状态版本开始的测试,但它跳过了事件重放,不能替代溯源行为验证。
聚合规格:事件与状态一起断言
下面的最小场景来自当前 CartSpec。它从未初始化聚合开始,设置 owner,执行添加商品命令,然后同时验证事件、业务状态和聚合元数据:
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.AggregateSpec
class CartSpec : AggregateSpec<Cart, CartState>({
on {
val ownerId = generateGlobalId()
val addCartItem = AddCartItem(
productId = "productId",
quantity = 1,
)
givenOwnerId(ownerId)
whenCommand(addCartItem) {
expectNoError()
expectEventType(CartItemAdded::class)
expectState {
items.assert().hasSize(1)
}
expectStateAggregate {
this.ownerId.assert().isEqualTo(ownerId)
}
}
}
})事件断言证明命令决策,状态断言证明事件已经由溯源函数正确应用。只断言其中一侧会漏掉另一侧的回归。
拒绝路径
拒绝路径应断言具体错误,并在重要场景确认状态或聚合元数据未被推进。当前 OrderSpec 覆盖了空商品、库存不足、价格不一致、未支付发货以及删除后继续操作等反例。
fork("Ship Before Payment") {
val shipOrder = ShipOrder(stateAggregate.aggregateId.id)
whenCommand(shipOrder) {
expectErrorType(IllegalStateException::class)
expectState {
paidAmount.assert().isEqualTo(BigDecimal.ZERO)
status.assert().isEqualTo(OrderStatus.CREATED)
}
}
}不要把所有失败都写成 expectError()。错误类型属于业务契约时,使用 expectErrorType(...) 使规格能区分不同拒绝原因。
分支与引用点
fork 从一个已验证状态继续执行,适合订单创建后的支付、发货、收货,以及同一起点的非法转换。每个分支拥有独立的后续状态,不会污染兄弟分支。
在当前 Expect 阶段直接分支:
fork(name = "Remove CartItem") {
whenCommand(RemoveCartItem(setOf(addCartItem.productId))) {
expectEventType(CartItemRemoved::class)
expectState {
items.assert().isEmpty()
}
}
}需要稍后从同一点展开时,先 ref("AggregateDeleted"),再在顶层使用 fork(ref = "AggregateDeleted", ...)。引用点应代表已经验证过的业务状态,而不是仅为减少几行设置代码。
fork(ref = "AggregateDeleted", name = "Recover") {
whenCommand(DefaultRecoverAggregate) {
expectNoError()
expectStateAggregate {
deleted.assert().isFalse()
}
fork(name = "Recover Again") {
whenCommand(DefaultRecoverAggregate) {
expectErrorType(IllegalStateException::class)
}
}
}
}注入领域依赖
当命令处理函数依赖领域规格服务时,通过 inject 注册测试实现。当前 OrderSpec 为 DefaultCreateOrderSpec 注入库存和定价服务,从而分别覆盖成功、库存不足和价格不一致。
inject {
register(DefaultCreateOrderSpec(inventoryService, pricingService))
}
whenCommand(CreateOrder(orderItems, SHIPPING_ADDRESS, false)) {
expectNoError()
expectEventType(OrderCreated::class)
expectState {
status.assert().isEqualTo(OrderStatus.CREATED)
totalAmount.assert().isEqualTo(totalAmount)
}
}这里注入的是领域边界的测试实现。真实网络客户端、数据库和 Broker 不属于该层,应放到应用集成测试。
无状态 Saga 规格
SagaSpec 的 When 是输入事件,Expect 是 Saga 发送的命令。当前 CartSagaSpec 验证从购物车创建订单后删除对应商品:
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.SagaSpec
class CartSagaSpec : SagaSpec<CartSaga>({
on {
val ownerId = generateGlobalId()
val orderItem = OrderItem(
id = generateGlobalId(),
productId = generateGlobalId(),
price = BigDecimal.valueOf(10),
quantity = 10,
)
whenEvent(
event = mockk<OrderCreated> {
every { items } returns listOf(orderItem)
every { fromCart } returns true
},
ownerId = ownerId,
) {
expectCommandType(RemoveCartItem::class)
expectCommand<RemoveCartItem> {
aggregateId.id.assert().isEqualTo(ownerId)
body.productIds.assert().hasSize(1)
body.productIds.assert().first().isEqualTo(orderItem.productId)
}
}
}
})对应反例应使用 expectNoCommand(),例如 OrderCreated.fromCart == false 时不删除购物车商品。Saga 规格验证命令意图与内容;Broker 重投和外部副作用幂等仍需真实适配器测试。
选择最窄的断言
| 目标 | DSL |
|---|---|
| 没有错误 / 特定错误 | expectNoError() / expectErrorType(...) |
| 事件数量、顺序或类型 | expectEventCount、expectEventIterator、expectEventType |
| 事件体字段 | expectEventBody<E> { ... } |
| 业务状态 | expectState { ... } |
| owner、版本、删除标记等聚合元数据 | expectStateAggregate { ... } |
| Saga 命令数量、类型或内容 | expectCommandCount、expectCommandType、expectCommand<C> |
断言业务可观察结果,不要复制框架内部实现。Kotlin 值断言统一使用 .assert(),避免在同一套规格中混用断言风格。
覆盖率证据怎么读
当前仓库的 :example-domain 在 Gradle 中为 jacocoTestCoverageVerification 配置 0.8 下限,并让该任务依赖 test 和报告生成。只有显式运行 :example-domain:jacocoTestCoverageVerification 才会执行这个阈值;当前 :example-domain:check 和 CI 工作流都没有自动挂载它。这是可选择执行的仓库覆盖率门禁,不是 wow-test 自动保证的覆盖率,也不是所有应用都必须照搬的数值。
旧文档截图、历史覆盖率或经验性缺陷数据只能说明当时的样本。评价当前变更时,以本次测试输出、当前覆盖率报告和项目自己的阈值为准。
运行与下一层
在本仓库验证示例与 DSL:
./gradlew :wow-test:check :example-domain:check \
:example-domain:jacocoTestCoverageVerification业务应用应改为自己的领域模块路径,并只在项目确实配置阈值时加入对应 verification task。该命令通过后,下一层是Wow 应用测试:验证生成元数据、运行时装配、HTTP、真实 Adapter、重启恢复与安全反例。修改 Wow 框架本身时,则使用框架测试与基准中的仓库任务。