测试领域代码
领域代码用写它时的语言来测试:已经发生了什么,来了一条什么命令,期望得到这些事件和这个状态。wow-test DSL 在真实的内核和内存日志上运行你的聚合,通过编译器为处理器生成的绑定运行你的处理器。领域测试不需要服务、数据库或 broker。本页说明怎样测试每一类领域代码。
准备
把制品 me.ahoo.wow:wow-test 加进测试依赖;它的版本来自 Wow BOM。这个 DSL 是包 me.ahoo.wow.test 中一组由 JUnit 测试调用的函数:聚合用 aggregateSpec;处理器用 sagaSpec、projectionSpec 和 reactorSpec;定时任务用 scheduleSpec。aggregateSpec、sagaSpec、projectionSpec 和 reactorSpec 各有一个以 Tests 结尾的孪生函数,把同样的规格变成一棵测试树。
JUnit 测试函数必须返回 Unit。这些规格函数返回 Unit,所以 fun test() = aggregateSpec(...) 没有问题;Tests 孪生函数返回测试树,供 @TestFactory 使用。
聚合
快速开始里的银行账户,这样测试:
package me.ahoo.wow.example.quickstart
import me.ahoo.test.asserts.assert
import me.ahoo.wow.test.aggregateSpec
import org.junit.jupiter.api.Test
class AccountSpec {
private val model = accountModel("bank")
@Test
fun `opening an account names its owner`() = aggregateSpec(model, id = "alice") {
whenCommand(OpenAccount("Alice")) {
expectEvents(AccountOpened("Alice"))
expectState { owner.assert().isEqualTo("Alice") }
}
}
@Test
fun `a deposit adds to the balance`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"))
whenCommand(Deposit(100)) {
expectEvents(Deposited(100))
expectState { balance.assert().isEqualTo(100) }
}
}
@Test
fun `a withdrawal the balance does not cover is rejected`() = aggregateSpec(model, id = "alice") {
given(AccountOpened("Alice"), Deposited(100))
whenCommand(Withdraw(150)) { expectRejected(Account.Rejections.INSUFFICIENT_BALANCE) }
whenCommand(Withdraw(30)) {
expectEvents(Withdrawn(30))
expectState { balance.assert().isEqualTo(70) }
}
}
}accountModel("bank") 是编译器为 Account 聚合生成的模型工厂。工厂接收上下文的名字,以及命令函数需要的领域服务;传入它们的桩。aggregateSpec 运行一个聚合,即 tenantId 中的 id(默认是 tenant 中的 aggregate-1)。
准备与执行
| 步骤 | 作用 |
|---|---|
given(vararg events, scope = null) | 把这些事件作为一条流提交,就像之前的某条命令产生了它们;scope 让聚合处于该所有者和空间。 |
givenState(state, version, scope)、givenState(version, scope) { … } | 让聚合从某个版本的某个状态开始,不重放历史;块形式修改初始状态。必须是规格的第一步。 |
givenReservation(namespace, key, owner) | 把一个预留键交给另一个聚合,于是预留它的命令会被拒绝。 |
whenCommand(command, requestId, expectedVersion, scope, …) { … } | 以 scope 中的调用方,把命令送进内核,并在块中检查它的结果。 |
advanceTime(duration) { … } | 拨动时钟,并把聚合给自己定时、到那时已到期的每条命令发给它;块检查最后一条。 |
expectNothingDue(within) | 拨动时钟;到那时若有聚合给自己定时的命令到期,就失败。 |
ref(name)、fork(name, from) { … } | 标记一个位置;在聚合的副本上运行一个场景,从现在或从标记的位置开始。 |
holds(namespace, key) | 聚合是否持有某个预留键。 |
时间停在 start(默认 2026-01-01T00:00:00Z),直到 advanceTime 推进它。决定函数拿到的 ID 是顺序的:id-1、id-2,依此类推。
断言
| 断言 | 通过条件 |
|---|---|
expectEvents(vararg events) | 命令恰好按顺序提交了这些事件。 |
expectEventTypes(vararg types) | 恰好按顺序提交了这些类的事件。 |
expectEvent<E> { … } | 恰好提交了一个事件,它是 E,且块中的检查在它上面成立。 |
expectUnchanged() | 被接受,且没有改变任何东西。 |
expectRejected(code) { … } | 以这个错误码被拒绝;块检查这个拒绝。 |
expectError<T> { … } | 决定函数抛出了 T。 |
expectNoError() | 没有抛出异常,命令也没有被拒绝。 |
expectState { … } | 块中的检查在命令之后的状态上成立。 |
expectScope(scope)、expectTags(tags) | 命令之后,聚合处于这个作用域,或者恰好带有这些标签。 |
expectScheduled(key, command, rule)、expectScheduleCancelled(key) | 在 key 下定时了这条命令,或取消了这个定时。 |
拒绝是一个值,按错误码检查。决定函数抛出的异常是失败,按类型检查;没有被任何 expectError 检查的异常,会像被抛出时那样让测试失败。命令什么都没提交时,expectState 检查的是聚合当前所处的状态。
决定函数不能修改状态。 在规格中,内核比较每个命令函数和 after 钩子前后的状态,若其中某个直接修改了状态,测试失败。不经过事件的修改在运行时会与日志渐渐不一致;测试会把它抓出来。
从某个状态开始
当一个场景从一段很长的历史会留下的状态开始时,直接从那个状态开始,不必重放历史。之后的命令基于它做决定,它们的流从它的版本接着往下:
@Test
fun `a payment given as succeeded should be refunded`() = paymentSpec {
givenState(version = 2) {
onSourcing(PaymentRequested(ORDER, AMOUNT))
onSourcing(PaymentSucceeded(TRANSACTION))
}
whenCommand(RefundPayment(REFUSED), expectedVersion = 2) {
expectEvents(PaymentRefunded(REFUSED))
expectState { transactionId.assert().isEqualTo(TRANSACTION) }
}
}状态像快照一样经 JSON 复制,所以它必须能序列化。
分叉开头相同的场景
开头相同的场景从这个开头分叉,而不是重复它。每个分叉运行在聚合自己的副本上:它的日志、定时器、预留、ID 和时钟。分叉里做的事留在分叉里,分叉之前的步骤也不会再运行,所以决定函数之前调用过的领域服务不会再被调用。ref 标记一个之后可以从它分叉的位置:
@Test
fun `a requested payment should end confirmed and refunded, or failed`() = paymentSpec {
whenCommand(RequestPayment(ORDER, AMOUNT)) { expectEventTypes(PaymentRequested::class) }
ref("requested")
fork("confirmed") {
whenCommand(ConfirmPayment(TRANSACTION)) { expectEventTypes(PaymentSucceeded::class) }
whenCommand(RefundPayment(REFUSED)) { expectEvents(PaymentRefunded(REFUSED)) }
}
fork("failed") {
whenCommand(FailPayment(DECLINED)) { expectEventTypes(PaymentFailed::class) }
whenCommand(ConfirmPayment(TRANSACTION)) {
expectRejected(Payment.Rejections.WRONG_STATUS)
expectState { status.assert().isEqualTo(PaymentStatus.FAILED) }
}
}
// The forks left the payment as requested.
whenCommand(RefundPayment(REFUSED)) {
expectRejected(Payment.Rejections.WRONG_STATUS)
expectState { status.assert().isEqualTo(PaymentStatus.REQUESTED) }
}
fork(from = "requested") {
whenCommand(FailPayment(DECLINED)) { expectEvents(PaymentFailed(DECLINED)) }
}
}分叉里的失败会写明是哪个分叉:In fork "failed": …。分叉可以嵌套,名字会连在一起。
测试树
aggregateSpecTests 把同一种规格作为一棵测试树运行,供 JUnit 的 @TestFactory 使用:每条命令是一个测试,每个分叉是一个包含它所运行测试的容器。失败的检查只让它自己的测试失败;其他步骤和分叉照常运行,各自报告:
/**
* The same lifecycle as a tree of tests: each command is a test and each fork a container, forked from the checks
* of the request, so a failing branch is reported on its own and the others still run.
*/
@TestFactory
fun `a payment's lifecycle`() = aggregateSpecTests(paymentModel(EshopPaymentContract.NAME), id = ORDER) {
whenCommand(RequestPayment(ORDER, AMOUNT)) {
expectEventTypes(PaymentRequested::class)
fork("confirmed") {
whenCommand(ConfirmPayment(TRANSACTION)) {
expectEventTypes(PaymentSucceeded::class)
fork("refunded") {
whenCommand(RefundPayment(REFUSED)) { expectEvents(PaymentRefunded(REFUSED)) }
whenCommand(ConfirmPayment(TRANSACTION)) { expectRejected(Payment.Rejections.WRONG_STATUS) }
}
}
whenCommand(FailPayment("too late")) { expectRejected(Payment.Rejections.WRONG_STATUS) }
}
fork("failed") {
whenCommand(FailPayment(DECLINED)) { expectEvents(PaymentFailed(DECLINED)) }
whenCommand(RefundPayment(REFUSED)) { expectRejected(Payment.Rejections.WRONG_STATUS) }
}
}
whenCommand(RefundPayment(REFUSED)) { expectRejected(Payment.Rejections.WRONG_STATUS) }
}命令检查块里的分叉,从那条命令的结果分出去。
定时命令
为自己的聚合定时一条命令的命令函数,靠拨动时钟来测试。advanceTime 像运行时的定时器那样,发送每条到期的命令:
@Test
fun `an unpaid order should cancel itself once its grace period has passed`() = orderSpec {
given(submitted, OrderAwaitingValidation, OrderStockItemConfirmed(MUG))
whenCommand(
ConfirmOrderStock(CAP)
) { expectScheduled(Order.PAYMENT_GRACE_PERIOD, CancelOrder(Order.PAYMENT_TIMEOUT), ScheduleRule.after(GRACE)) }
advanceTime(GRACE) {
expectEvents(OrderCancelled(Order.PAYMENT_TIMEOUT))
expectState {
status.assert().isEqualTo(OrderStatus.CANCELLED)
cancelReason.assert().isEqualTo(Order.PAYMENT_TIMEOUT)
}
}
}cron 规则像运行时那样逐个时间槽推进:一次越过多个时间槽时只发送最近的那一个,除非规则要求补发。
Saga
处理器通过编译器为它生成的处理函数来测试:对每个 @Saga、@Projection 或 @Reactor 类,在它的包里有一个函数 <processor>Handlers(dependencies)。它只构建这一个处理器,并从 dependencies 取它自己的服务。
sagaSpec 像订阅那样,把一次提交交给读取它的 Saga 处理函数,并检查它们发出的命令。stateEvent(ref, state, vararg events, version, …) 构建这次提交:
@Test
fun `an order awaiting validation should reserve each product's stock in the inventory`() = sagaSpec(handlers) {
whenEvent(event(OrderAwaitingValidation)) {
commands.map { it.ref to it.body }.assert().containsExactly(
stock.ref(TENANT, "mug") to ReserveStock(ORDER, 5),
stock.ref(TENANT, "cap") to ReserveStock(ORDER, 1),
)
// Handling the commit again derives the same requests, which the inventory decides once: the spec
// gives each handling a fresh context, as the runtime does.
expectDeterministic()
}
}expectDeterministic() 再处理一次这次提交,像运行时那样给每次处理一个新的上下文,并检查 Saga 发出相同的命令、带相同的 ID。正是这一点让重新投递的提交是安全的:目标对每个请求只决定一次。
拒绝与回执
Saga 发出的命令可能被目标拒绝,或者带回执地提交。whenRejected 和 whenCommitted 像运行时那样,把这个结果交回给 Saga 的 @OnRejected 或 @OnCommitted 处理函数:
@Test
fun `a reservation the inventory refused should cancel the order`() = sagaSpec(handlers) {
whenEvent(event(OrderAwaitingValidation)) {
val cap = commands.single { it.ref.id == "cap" }
whenRejected(cap, "eshop-inventory.stock.insufficient", "Only 0 of cap are available, not 1.") {
expectHandledBy("onReserveRejected")
expectCommand(CancelOrder("cap cannot be reserved: Only 0 of cap are available, not 1."), to = order)
// The same receipt again derives the same request.
expectDeterministic()
}
}
}同步调用
同步调用另一个服务的 Saga,从 answerCalls 拿到回答:一个结果、一个拒绝,或者模拟服务宕机的异常。expectCalls 和 calls 检查它调用了什么:
@Test
fun `an order the ordering context refuses should reach the saga as a rejection`() = sagaSpec(handlers) {
answerCalls { rejectedResult(it, EshopOrderingContract.Order.Errors.WRONG_STATUS) }
whenEvent(event(checkedOut)) {
expectError<CallRejectedException> {
code.assert().isEqualTo(
EshopOrderingContract.Order.Errors.WRONG_STATUS
)
}
}
}处理函数通过查询 API 读取的 Saga,从 givenQueries 拿到读到的内容,这是读侧的一个假实现。
Saga 的断言
| 断言 | 通过条件 |
|---|---|
expectCommands(vararg bodies)、expectCommandTypes(vararg types) | 处理函数恰好按顺序发出了这些命令体或这些类的命令。 |
expectCommand(body, to)、expectCommand<C> { … } | 恰好发出了一条命令,命令体是这个、发往这个聚合;或者是一个经块检查的 C。 |
expectNoCommand() | 什么都没发。 |
expectCalls(vararg bodies)、expectNoCall() | 恰好同步调用了这些命令,或者一个都没调用。 |
expectScheduled(…)、expectScheduleCancelled(…)、expectNoSchedule() | 定时或取消了一条命令,或者没有改变任何定时。 |
expectHandledBy(vararg functions) | 恰好按顺序调用了这些处理函数。 |
expectDeterministic() | 再处理一次这次提交或这个结果,发出相同的命令,包括 ID。 |
expectError<T> { … }、expectNoError() | 某个处理函数抛出了 T;没有处理函数抛出异常。 |
commands、calls、schedules 和 handled 给出原始结果,用于表里没有覆盖的检查。读取另一个上下文发布的事件流的 Saga,拿到的是那个上下文的事件;当对方上下文的类不在测试的类路径上时,用 publishedEvent(name, body, revision) 按名字构建一个。
投影与反应器
投影或反应器写入一个端口,例如仓储或邮件服务,测试为它提供假实现。把假实现传给处理器生成的处理函数,用 whenEvent 喂给它们提交,然后检查假实现里有什么。projectionSpec 和 reactorSpec 的步骤与 sagaSpec 相同,断言只有适用于它们的那些:expectHandledBy、expectError 和 expectNoError。把同一次提交喂两次,就能测试投影是否幂等。
定时任务
Saga 的 @OnSchedule 处理函数用 scheduleSpec 测试,针对的是真实的任务聚合。用 enable 启用任务的一个实例,用 advanceTime 拨动时钟,检查处理函数收到的触发和它们发出的命令。whenTick 直接交给它一次触发。
编译器生成了什么
领域模块里的测试可以加载编译器为它生成的东西,检查模块的绑定索引和 Manifest 片段:它指明哪个上下文、构建哪些模型、它的处理器变成哪些订阅。
@Test
fun `the binding index should name the context and the fragment and build the order model`() {
provider.context.assert().isEqualTo("eshop-ordering")
provider.fragment.assert().isEqualTo("META-INF/wow/manifest/eshop-ordering-domain.json")
provider.models(Dependencies.of(PaymentPolicy(30.minutes))).map { it.type.toString() }
.assert().containsExactly("eshop-ordering.order")
val ports = Dependencies.of(OrderSummaries { _, _ -> }, BuyerNotifications { _, _, _ -> })
provider.processors(ports).map { it.name }.assert().containsExactlyInAnyOrder(
"OrderProcessSaga.onOrderSubmitted",
"OrderProcessSaga.onOrderAwaitingValidation",
"OrderProcessSaga.onOrderCancelled",
"OrderProcessSaga.onOrderShipped",
"OrderSummaryProjection.onEvent",
"ShipmentNotifier.onOrderShipped",
)
}processors(dependencies) 构建模块的所有处理器,所以需要它们全部的服务。只需要自己服务的处理器测试,改用它生成的处理函数。
领域测试之外
领域测试覆盖模型。服务测试启动应用并经 HTTP 调用它;只把它用在只有运行中的服务才能体现的东西上,例如路由、授权和配置:
- 在内存后端上运行,公开服务器和内部服务器使用随机端口,内部端口绑定在回环网卡上。
- 给测试一个自己的调用方解析器,读取一个测试用的请求头,让每个请求都有租户和所有者。
- 在内存后端上,只有设置
wow.query.memory.enabled: true时快照查询才会回答。 - Saga 向另一个上下文发命令的服务需要命令传输:容器里的 Kafka,或者
wow.topics.in-memory: true。 - 针对 PostgreSQL、MongoDB、Kafka 或 ClickHouse 的测试在容器里运行,放在模块的
integrationTest源集中,check不会运行它们。