使用withObjectMocked模拟的方法在Actor调用时未生效求助
将无状态组件从类重构为Scala object后,使用mockito-scala的withObjectMocked功能模拟外部服务连接,直接调用方法时模拟正常,但引入Akka Actor后,Actor会绕过模拟调用真实实现。
目标是模拟FooService.doit()方法(真实实现抛出RuntimeException,测试中禁止执行):
object FooService { def doit(): String = { // 测试中不应执行此逻辑 throw new RuntimeException("executed real impl!!!") } }
FooService.doit()仅由FooActor.handleDoit()调用,该方法可通过接收Doit消息或直接调用触发:
object FooActor { val outcome: Promise[Try[String]] = Promise[Try[String]]() case object Doit def apply(): Behavior[Doit.type] = Behaviors.receiveMessage { _ => handleDoit() Behaviors.same } def handleDoit(): Unit = { try { val res = FooService.doit() outcome.success(Success(res)) } catch { case ex: RuntimeException => outcome.success(Failure(ex)) } } }
使用withObjectMocked模拟FooService.doit(),所有测试代码都在该块内,并通过Await等待FooActor.outcome的结果:
withObjectMocked[FooService.type] { FooService.doit() returns "mock result" // 执行测试场景 }
设置两种测试场景:
- 直接调用:模拟正常工作
def simpleSetup(): Try[String] = { FooActor.handleDoit() Await.result(FooActor.outcome.future, 1.seconds) }
输出:finished with result: mock result
- 通过Actor触发:模拟失效,调用真实实现
def actorSetup(): Try[String] = { val system: ActorSystem[FooActor.Doit.type] = ActorSystem(FooActor(), "FooSystem") system ! FooActor.Doit val result = Await.result(FooActor.outcome.future, 1.seconds) system.terminate() result }
输出:failed with exception: executed real impl!!!
mockito-scala的withObjectMocked基于**线程局部存储(Thread-Local Storage)**实现,仅在创建mock的线程中生效。而Akka Actor运行在独立的线程池线程中,与测试代码所在线程并非同一个,因此Actor线程无法感知测试线程中创建的mock,最终调用了真实的FooService.doit()实现。
方案1:将FooService改为可注入的依赖
把FooService从object改为class,通过构造函数注入到Actor中,测试时直接传入mock实例,彻底绕过object的静态特性限制:
// 重构FooService为class class FooService { def doit(): String = { throw new RuntimeException("executed real impl!!!") } } // 重构FooActor,通过构造函数接收FooService依赖 object FooActor { val outcome: Promise[Try[String]] = Promise[Try[String]]() case object Doit def apply(fooService: FooService): Behavior[Doit.type] = Behaviors.receiveMessage { _ => handleDoit(fooService) Behaviors.same } def handleDoit(fooService: FooService): Unit = { try { val res = fooService.doit() outcome.success(Success(res)) } catch { case ex: RuntimeException => outcome.success(Failure(ex)) } } } // 测试代码 val mockFooService = mock[FooService] mockFooService.doit() returns "mock result" val system = ActorSystem(FooActor(mockFooService), "FooSystem") system ! FooActor.Doit val result = Await.result(FooActor.outcome.future, 1.seconds) system.terminate()
方案2:使用Akka TestKit规范异步测试
同时去掉全局outcome Promise,改用TestProbe接收Actor结果,让测试更符合Akka异步风格:
// 重构FooActor,通过sender传递结果 object FooActor { case object Doit case class DoitResult(result: Try[String]) def apply(fooService: FooService): Behavior[Doit.type] = Behaviors.receive { (context, msg) => val res = Try(fooService.doit()) context.sender() ! DoitResult(res) Behaviors.same } } // 测试代码(基于Akka TestKit) class FooActorSpec extends AnyWordSpec with TestKit with ImplicitSender with BeforeAndAfterAll { override def afterAll(): Unit = TestKit.shutdownActorSystem(system) "FooActor" should { "use mocked FooService when triggered" in { val mockFooService = mock[FooService] mockFooService.doit() returns "mock result" val actor = system.actorOf(FooActor(mockFooService).props()) actor ! FooActor.Doit val result = expectMsgType[FooActor.DoitResult] result.result shouldBe Success("mock result") } } }
方案3:临时线程上下文传播(不推荐)
若无法重构object,可尝试手动将mock上下文传播到Actor线程,但该方式依赖mockito-scala内部实现,稳定性较差:
withObjectMocked[FooService.type] { FooService.doit() returns "mock result" val mockContext = MockitoScalaSession.getCurrentSession.get val system = ActorSystem(Behaviors.receiveMessage[FooActor.Doit.type] { _ => // 在Actor线程激活mock上下文 MockitoScalaSession.withSession(mockContext) { FooActor.handleDoit() } Behaviors.same }, "FooSystem") system ! FooActor.Doit val result = Await.result(FooActor.outcome.future, 1.seconds) system.terminate() }
最可靠的方案是方案1,通过依赖注入将静态object改为可注入class,从根本上解决mock的线程限制问题,同时让代码更符合依赖倒置原则,提升可测试性。方案2进一步优化了测试的异步风格,更适配Akka应用的测试实践。
内容的提问来源于stack exchange,提问作者Zwackelmann

