PAYATHON 2026

为什么 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 表示继续停留在当前状态,并不意味着重新进入当前状态,也不会再次发布状态通知。

实际的消息处理顺序如下:

  1. BaristaIsAvailable(OrderPending, PaymentPending) 让 FSM 从 OrderPending 转换到 OrderPlaced。
  2. SubscribeTransitionCallBack(testActor) 注册订阅者。
  3. FSM 向新订阅者发送一次 CurrentState(orderSystem, OrderPlaced)。
  4. 第二个 BaristaIsAvailable 得到处理,并执行 stay。
  5. 状态没有变化,因此不会发送新的订阅通知。
  6. 第二个 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))

备注:内容仅供参考。