为什么 Akka FSM 事件会超时?
结论
SubscribeTransitionCallBack 并不是”每处理一个事件就报告一次当前状态”的订阅机制。
订阅成功后,FSM 会立即发送一次:
CurrentState(orderSystem, OrderPlaced)
之后,只有状态名称发生变化,订阅者才会收到:
Transition(orderSystem, fromState, toState)
第二个事件匹配到 stay 后,FSM 仍处于 OrderPlaced,没有发生状态转换,自然也不会发送新的 CurrentState 或 Transition。因此,测试中的第二次 expectMsg(CurrentState(...)) 一直等不到消息,最终超时。
为什么日志显示事件已处理,测试仍然失败
下面这段处理逻辑确实执行了:
case Event(_, Data(_, PaymentPending)) =>
println("4")
stay
日志中出现 4,说明事件匹配成功。接着执行的 stay 表示继续停留在当前状态,并不意味着重新进入当前状态,也不会再次发布状态通知。
实际的消息处理顺序如下:
BaristaIsAvailable(OrderPending, PaymentPending)让 FSM 从OrderPending转换到OrderPlaced。SubscribeTransitionCallBack(testActor)注册订阅者。- FSM 向新订阅者发送一次
CurrentState(orderSystem, OrderPlaced)。 - 第二个
BaristaIsAvailable得到处理,并执行stay。 - 状态没有变化,因此不会发送新的订阅通知。
- 第二个
expectMsg因等待不到消息而超时。
这里容易混淆的是,CurrentState 是建立订阅时发送的状态快照,并非每个事件处理完成后的确认消息。
验证订阅者没有收到状态转换
如果测试是为了确认该事件没有触发状态转换,可以断言订阅者没有收到消息:
it should "not transition while customer has not paid" in {
val orderSystem = system.actorOf(Props[OrderSystem])
orderSystem ! BaristaIsAvailable(OrderPending, PaymentPending)
orderSystem ! SubscribeTransitionCallBack(testActor)
expectMsg(CurrentState(orderSystem, OrderPlaced))
orderSystem ! BaristaIsAvailable(OrderPlaced, PaymentPending)
expectNoMessage()
}
这个测试可以证明订阅者没有观察到新的转换。不过严格来说,expectNoMessage() 只能确认”没有收到转换通知”,不能单独证明 FSM 此时一定处于 OrderPlaced。
更可靠的做法:增加状态查询协议
在黑盒测试中,可以为 FSM 增加显式查询当前状态的协议:
case object GetState
final case class StateSnapshot(state: State, data: Data)
然后在 whenUnhandled 中处理查询:
whenUnhandled {
case Event(GetState, data) =>
sender() ! StateSnapshot(stateName, data)
stay
case Event(e, s) =>
log.warning(
"Received unhandled request {} in state {}/{}",
e,
stateName,
s
)
stay
}
测试可以直接查询状态:
it should "stay in OrderPlaced state as long as customer has not paid" in {
val orderSystem = system.actorOf(Props[OrderSystem])
orderSystem ! BaristaIsAvailable(OrderPending, PaymentPending)
orderSystem ! SubscribeTransitionCallBack(testActor)
expectMsg(CurrentState(orderSystem, OrderPlaced))
orderSystem ! BaristaIsAvailable(OrderPlaced, PaymentPending)
orderSystem ! GetState
expectMsgPF() {
case StateSnapshot(OrderPlaced, Data(_, PaymentPending)) =>
succeed
}
}
测试 Actor 依次向同一个 FSM Actor 发送事件和 GetState。Actor 的消息顺序保证前一个事件会先得到处理,然后才处理查询。这样检查的是 FSM 的实际状态,不需要根据是否收到通知来间接推断。
使用 TestFSMRef 进行白盒测试
如果当前使用的 Akka Classic 测试版本提供 TestFSMRef,也可以直接检查 stateName:
import akka.testkit.TestFSMRef
it should "stay in OrderPlaced state as long as customer has not paid" in {
val orderSystem = TestFSMRef(new OrderSystem)
orderSystem ! BaristaIsAvailable(OrderPending, PaymentPending)
orderSystem.stateName shouldBe OrderPlaced
orderSystem ! BaristaIsAvailable(OrderPlaced, PaymentPending)
orderSystem.stateName shouldBe OrderPlaced
}
这种写法更简短,但测试会与 FSM 的内部实现产生更紧密的耦合。显式的 GetState 查询协议通常更适合黑盒测试,也更接近 Actor 的实际使用方式。
注意事项
CurrentState只会在订阅时发送一次。- 后续发生状态变化时,FSM 发送的是
Transition,不是CurrentState。 stay不会产生状态转换通知。expectNoMessage()可以验证”没有收到转换通知”,但不能直接验证当前状态。- 如果需要确认 FSM 的当前状态,优先使用显式查询消息。白盒单元测试也可以使用
TestFSMRef。 - 如果测试的是真正的状态转换,应匹配类似下面的消息:
expectMsg(Transition(orderSystem, OrderPlaced, OrderReady))
备注:内容仅供参考。
