This is an automated email from the ASF dual-hosted git repository. hepin pushed a commit to branch behaviorKit in repository https://gitbox.apache.org/repos/asf/pekko.git
commit d297de2122d8d8c0f0727f4a1e36220abe138cdf Author: He-Pin <[email protected]> AuthorDate: Sat Nov 8 11:09:25 2025 +0800 feat: Add effectful asking support in typed BehaviorTestKit --- .../apache/pekko/actor/testkit/typed/Effect.scala | 57 ++++++- .../typed/internal/BehaviorTestKitImpl.scala | 9 +- .../typed/internal/EffectfulActorContext.scala | 67 +++++++- .../actor/testkit/typed/javadsl/Effects.scala | 16 +- .../actor/testkit/typed/scaladsl/Effects.scala | 15 +- .../typed/javadsl/SyncTestingExampleTest.java | 119 +++++++++++++++ .../testkit/typed/javadsl/BehaviorTestKitTest.java | 63 ++++++++ .../typed/scaladsl/SyncTestingExampleSpec.scala | 71 +++++++++ .../typed/scaladsl/BehaviorTestKitSpec.scala | 170 +++++++++++++++++++++ docs/src/main/paradox/typed/testing-sync.md | 10 ++ 10 files changed, 590 insertions(+), 7 deletions(-) diff --git a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/Effect.scala b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/Effect.scala index 342826dde9..32c3ce7998 100644 --- a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/Effect.scala +++ b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/Effect.scala @@ -13,13 +13,16 @@ package org.apache.pekko.actor.testkit.typed +import java.util.concurrent.TimeoutException + import scala.annotation.nowarn import scala.concurrent.duration.FiniteDuration import scala.jdk.DurationConverters._ import scala.jdk.FunctionConverters._ +import scala.util.{ Failure, Success, Try } import org.apache.pekko -import pekko.actor.typed.{ ActorRef, Behavior, Props } +import pekko.actor.typed.{ ActorRef, Behavior, Props, RecipientRef } import pekko.annotation.{ DoNotInherit, InternalApi } /** @@ -36,6 +39,58 @@ abstract class Effect private[pekko] () object Effect { + /** + * The behavior initiated an ask via its context. A response or timeout may be sent via this + * effect to the asking behavior: this effect enforces that at most one response or timeout is + * sent. Alternatively, one may, after obtaining the effect, test the response adaptation function + * (without sending a message to the asking behavior) arbitrarily many times via the 'adaptResponse` + * and `adaptTimeout` methods. + * + * The 'replyToRef' is exposed so that the target inbox can expect the actual message sent to + * initiate the ask. + * + * Note that this requires the ask to be initiated via the [[ActorContext]]. The [[Future]] returning + * ask is not testable in the [[BehaviorTestKit]]. + */ + final case class AskInitiated[Req, Res, T](target: RecipientRef[Req], + responseTimeout: FiniteDuration, + responseClass: Class[Res])(val askMessage: Req, forwardResponse: Try[Res] => Unit, mapResponse: Try[Res] => T) + extends Effect { + def respondWith(response: Res): Unit = sendResponse(Success(response)) + + def timeout(): Unit = sendResponse(timeoutTry(timeoutMsg)) + + def adaptResponse(response: Res): T = mapResponse(Success(response)) + def adaptTimeout(msg: String): T = mapResponse(timeoutTry(msg)) + def adaptTimeout: T = adaptTimeout(timeoutMsg) + + /** + * Java API + */ + def getResponseTimeout: java.time.Duration = responseTimeout.toJava + + private var sentResponse: Boolean = false + + private def timeoutTry(msg: String): Try[Res] = Failure(new TimeoutException(msg)) + + private def timeoutMsg: String = + s"Ask timed out on [$target] after [${responseTimeout.toMillis} ms]. " + + s"Message of type [${askMessage.getClass.getName}]." + + " A typical reason for `AskTimeoutException` is that the recipient actor didn't send a reply." + + private def sendResponse(t: Try[Res]): Unit = synchronized { + if (sentResponse) { + throw new IllegalStateException("Can only complete the ask once") + } + + sentResponse = true + + if (forwardResponse != null) { + forwardResponse(t) + } else throw new IllegalStateException("Can only complete and ask from a BehaviorTestKit-emitted effect") + } + } + /** * The behavior spawned a named child with the given behavior (and optionally specific props) */ diff --git a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/BehaviorTestKitImpl.scala b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/BehaviorTestKitImpl.scala index 29e3c371cb..d9c3aa8686 100644 --- a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/BehaviorTestKitImpl.scala +++ b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/BehaviorTestKitImpl.scala @@ -27,7 +27,7 @@ import pekko.actor.ActorPath import pekko.actor.testkit.typed.{ CapturedLogEvent, Effect } import pekko.actor.testkit.typed.Effect._ import pekko.actor.typed.{ ActorRef, Behavior, BehaviorInterceptor, PostStop, Signal, TypedActorContext } -import pekko.actor.typed.internal.AdaptWithRegisteredMessageAdapter +import pekko.actor.typed.internal.{ AdaptMessage, AdaptWithRegisteredMessageAdapter } import pekko.actor.typed.receptionist.Receptionist import pekko.actor.typed.scaladsl.Behaviors import pekko.annotation.InternalApi @@ -45,7 +45,7 @@ private[pekko] final class BehaviorTestKitImpl[T]( // really this should be private, make so when we port out tests that need it private[pekko] val context: EffectfulActorContext[T] = - new EffectfulActorContext[T](system, _path, () => currentBehavior) + new EffectfulActorContext[T](system, _path, () => currentBehavior, this) private[pekko] def as[U]: BehaviorTestKitImpl[U] = this.asInstanceOf[BehaviorTestKitImpl[U]] @@ -205,6 +205,11 @@ private[pekko] object BehaviorTestKitImpl { val adaptedMsg = fn(msgToAdapt) target.apply(ctx, adaptedMsg) + + case AdaptMessage(msgToAdapt, messageAdapter) => + val adaptedMsg = messageAdapter(msgToAdapt) + target.apply(ctx, adaptedMsg) + case t => target.apply(ctx, t) } } diff --git a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/EffectfulActorContext.scala b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/EffectfulActorContext.scala index ed2b47bea5..10d1a0bde8 100644 --- a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/EffectfulActorContext.scala +++ b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/EffectfulActorContext.scala @@ -13,16 +13,21 @@ package org.apache.pekko.actor.testkit.typed.internal +import java.time.Duration import java.util.concurrent.ConcurrentLinkedQueue import scala.concurrent.duration.FiniteDuration +import scala.jdk.DurationConverters._ import scala.reflect.ClassTag +import scala.util.Try +import scala.util.control.NonFatal import org.apache.pekko +import org.apache.pekko.util.Timeout import pekko.actor.{ ActorPath, Cancellable } import pekko.actor.testkit.typed.Effect import pekko.actor.testkit.typed.Effect._ -import pekko.actor.typed.{ ActorRef, Behavior, Props } +import pekko.actor.typed.{ ActorRef, Behavior, Props, RecipientRef } import pekko.actor.typed.internal.TimerSchedulerCrossDslSupport import pekko.annotation.InternalApi @@ -32,11 +37,69 @@ import pekko.annotation.InternalApi @InternalApi private[pekko] final class EffectfulActorContext[T]( system: ActorSystemStub, path: ActorPath, - currentBehaviorProvider: () => Behavior[T]) + currentBehaviorProvider: () => Behavior[T], + behaviorTestKit: BehaviorTestKitImpl[T]) extends StubbedActorContext[T](system, path, currentBehaviorProvider) { private[pekko] val effectQueue = new ConcurrentLinkedQueue[Effect] + override def ask[Req, Res](target: RecipientRef[Req], createRequest: ActorRef[Res] => Req)( + mapResponse: Try[Res] => T)(implicit responseTimeout: Timeout, classTag: ClassTag[Res]): Unit = { + // In the real implementation, this propagates as an immediately-failed future, + // but since an illegal timeout is the sort of thing that ideally would have been + // a type error, blowing up the test is the next-best thing + require(responseTimeout.duration.length > 0, s"Timeout length must be positive, question not sent to [$target]") + + val responseClass = classTag.runtimeClass.asInstanceOf[Class[Res]] + + commonAsk(responseClass, createRequest, target, responseTimeout.duration, mapResponse) + } + + override def ask[Req, Res]( + resClass: Class[Res], + target: RecipientRef[Req], + responseTimeout: Duration, + createRequest: pekko.japi.function.Function[ActorRef[Res], Req], + applyToResponse: pekko.japi.function.Function2[Res, Throwable, T]): Unit = { + require( + responseTimeout.getSeconds > 0 || responseTimeout.getNano > 0, + s"Timeout length must be positive, question not sent to [$target]") + + val scalaCreateRequest = createRequest(_) + val scalaMapResponse = { (result: Try[Res]) => + result + .map(applyToResponse(_, null)) + .recover { + case NonFatal(ex) => applyToResponse(null.asInstanceOf[Res], ex) + } + .get + } + + commonAsk(resClass, scalaCreateRequest, target, responseTimeout.toScala, scalaMapResponse) + } + + private def commonAsk[Req, Res]( + responseClass: Class[Res], + createRequest: ActorRef[Res] => Req, + target: RecipientRef[Req], + responseTimeout: FiniteDuration, + mapResponse: Try[Res] => T): Unit = { + val replyTo = system.ignoreRef[Res] + val askMessage = createRequest(replyTo) + target ! askMessage + + val responseForwarder = { (t: Try[Res]) => + import pekko.actor.typed.internal.AdaptMessage + + // Yay erasure + val adaptedTestKit = behaviorTestKit.asInstanceOf[BehaviorTestKitImpl[AdaptMessage[Try[Res], T]]] + + adaptedTestKit.run(AdaptMessage(t, mapResponse)) + } + + effectQueue.offer(AskInitiated(target, responseTimeout, responseClass)(askMessage, responseForwarder, mapResponse)) + } + override def spawnAnonymous[U](behavior: Behavior[U], props: Props = Props.empty): ActorRef[U] = { val ref = super.spawnAnonymous(behavior, props) effectQueue.offer(new SpawnedAnonymous(behavior, props, ref)) diff --git a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/Effects.scala b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/Effects.scala index ad7d77d4a4..59800e9b8a 100644 --- a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/Effects.scala +++ b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/Effects.scala @@ -18,7 +18,7 @@ import java.time.Duration import scala.jdk.DurationConverters._ import org.apache.pekko -import pekko.actor.typed.{ ActorRef, Behavior, Props } +import pekko.actor.typed.{ ActorRef, Behavior, Props, RecipientRef } /** * Factories for behavior effects for [[BehaviorTestKit]], each effect has a suitable equals and can be used to compare @@ -27,6 +27,20 @@ import pekko.actor.typed.{ ActorRef, Behavior, Props } object Effects { import org.apache.pekko.actor.testkit.typed.Effect._ + /** + * The behavior initiated an ask via its context. Note that the effect returned from this method should only + * be used to compare with an actual effect. + * + * @since 1.3.0 + */ + @annotation.nowarn("msg=never used") // messageClass is just a pretend param + def askInitiated[Req, Res, T]( + target: RecipientRef[Req], + responseTimeout: Duration, + responseClass: Class[Res], + messageClass: Class[T]): AskInitiated[Req, Res, T] = + AskInitiated(target, responseTimeout.toScala, responseClass)(null.asInstanceOf[Req], null, null) + /** * The behavior spawned a named child with the given behavior with no specific props */ diff --git a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/scaladsl/Effects.scala b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/scaladsl/Effects.scala index 0dcc8b9b82..9b6e80eca0 100644 --- a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/scaladsl/Effects.scala +++ b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/scaladsl/Effects.scala @@ -16,7 +16,7 @@ package org.apache.pekko.actor.testkit.typed.scaladsl import scala.concurrent.duration.FiniteDuration import org.apache.pekko -import pekko.actor.typed.{ ActorRef, Behavior, Props } +import pekko.actor.typed.{ ActorRef, Behavior, Props, RecipientRef } /** * Factories for behavior effects for [[BehaviorTestKit]], each effect has a suitable equals and can be used to compare @@ -25,6 +25,19 @@ import pekko.actor.typed.{ ActorRef, Behavior, Props } object Effects { import pekko.actor.testkit.typed.Effect._ + /** + * The behavior initiated an ask via its context. Note that the effect returned + * from this method should only be used for an equality comparison with the actual + * effect from running the behavior. + * + * @since 1.3.0 + */ + def askInitiated[Req, Res, T]( + target: RecipientRef[Req], + responseTimeout: FiniteDuration, + responseClass: Class[Res]): AskInitiated[Req, Res, T] = + AskInitiated(target, responseTimeout, responseClass)(null.asInstanceOf[Req], null, null) + /** * The behavior spawned a named child with the given behavior with no specific props */ diff --git a/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java b/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java index f132a13504..fc46a0f8d8 100644 --- a/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java +++ b/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java @@ -15,8 +15,10 @@ package jdocs.org.apache.pekko.actor.testkit.typed.javadsl; // #imports import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import com.typesafe.config.Config; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Optional; @@ -88,6 +90,50 @@ public class SyncTestingExampleTest extends JUnitSuite { } } + public static class AskAQuestion implements Command { + public final ActorRef<Question> who; + + public AskAQuestion(ActorRef<Question> who) { + this.who = who; + } + } + + public static class GotAnAnswer implements Command { + public final String answer; + public final ActorRef<Question> from; + + public GotAnAnswer(String answer, ActorRef<Question> from) { + this.answer = answer; + this.from = from; + } + } + + public static class NoAnswerFrom implements Command { + public final ActorRef<Question> whom; + + public NoAnswerFrom(ActorRef<Question> whom) { + this.whom = whom; + } + } + + public static class Question { + public final String q; + public final ActorRef<Answer> replyTo; + + public Question(String q, ActorRef<Answer> replyTo) { + this.q = q; + this.replyTo = replyTo; + } + } + + public static class Answer { + public final String a; + + public Answer(String a) { + this.a = a; + } + } + public static Behavior<Command> create() { return Behaviors.setup(Hello::new); } @@ -105,6 +151,9 @@ public class SyncTestingExampleTest extends JUnitSuite { .onMessage(SayHelloToAnonymousChild.class, this::onSayHelloToAnonymousChild) .onMessage(SayHello.class, this::onSayHello) .onMessage(LogAndSayHello.class, this::onLogAndSayHello) + .onMessage(AskAQuestion.class, this::onAskAQuestion) + .onMessage(GotAnAnswer.class, this::onGotAnAnswer) + .onMessage(NoAnswerFrom.class, this::onNoAnswerFrom) .build(); } @@ -140,6 +189,33 @@ public class SyncTestingExampleTest extends JUnitSuite { message.who.tell("hello"); return Behaviors.same(); } + + private Behavior<Command> onAskAQuestion(AskAQuestion message) { + getContext() + .ask( + Answer.class, + message.who, + Duration.ofSeconds(10), + (ActorRef<Answer> ref) -> new Question("do you know who I am?", ref), + (response, throwable) -> { + if (response != null) { + return new GotAnAnswer(response.a, message.who); + } else { + return new NoAnswerFrom(message.who); + } + }); + return Behaviors.same(); + } + + private Behavior<Command> onGotAnAnswer(GotAnAnswer message) { + getContext().getLog().info("Got an answer[{}] from {}", message.answer, message.from); + return Behaviors.same(); + } + + private Behavior<Command> onNoAnswerFrom(NoAnswerFrom message) { + getContext().getLog().info("Did not get an answer from {}", message.whom); + return Behaviors.same(); + } } // #under-test @@ -213,6 +289,49 @@ public class SyncTestingExampleTest extends JUnitSuite { // #test-check-logging } + @Test + public void testSupportContextualAsk() { + // #test-contextual-ask + BehaviorTestKit<Hello.Command> test = BehaviorTestKit.create(Hello.create()); + TestInbox<Hello.Question> askee = TestInbox.create(); + test.run(new Hello.AskAQuestion(askee.getRef())); + + Hello.Question question = askee.receiveMessage(); + // Note that the replyTo address in the message is not a priori predictable, so shouldn't be + // asserted against + assertEquals(question.q, "do you know who I am?"); + + // Retrieve a description of the performed ask + @SuppressWarnings("unchecked") + Effect.AskInitiated<Hello.Question, Hello.Answer, Hello.Command> effect = + test.expectEffectClass(Effect.AskInitiated.class); + + test.clearLog(); + + // The effect can be used to complete or time-out the ask at most once + effect.respondWith(new Hello.Answer("I think I met you somewhere, sometime")); + // commented out because we've completed the ask + // effect.timeout(); + + // Completing/timing-out the ask is processed synchronously + List<CapturedLogEvent> allLogEntries = test.getAllLogEntries(); + assertEquals(allLogEntries.size(), 1); + + // The message, including the synthesized "replyTo", can be inspected from the effect + assertEquals(question, effect.askMessage()); + + // The response adaptation can be tested as many times as you want without completing the ask + Hello.Command response1 = effect.adaptResponse(new Hello.Answer("No. Who are you?")); + assertEquals(((Hello.GotAnAnswer) response1).answer, "No. Who are you?"); + + // ... as can the message sent on a timeout + assertTrue(effect.adaptTimeout() instanceof Hello.NoAnswerFrom); + + // The response timeout is captured + assertEquals(effect.responseTimeout().toSeconds(), 10L); + // #test-contextual-ask + } + @Test public void testWithAppTestCfg() { diff --git a/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java b/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java index 549742b4a4..43d01da6d9 100644 --- a/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java +++ b/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java @@ -128,6 +128,34 @@ public class BehaviorTestKitTest extends JUnitSuite { } } + public static class AskForCookiesFrom implements Command { + private final ActorRef<CookieDistributorCommand> distributor; + + public AskForCookiesFrom(ActorRef<CookieDistributorCommand> distributor) { + this.distributor = distributor; + } + } + + public interface CookieDistributorCommand {} + + public static class GiveMeCookies implements CookieDistributorCommand { + public final int nrCookies; + public final ActorRef<CookiesForYou> replyTo; + + public GiveMeCookies(int nrCookies, ActorRef<CookiesForYou> replyTo) { + this.nrCookies = nrCookies; + this.replyTo = replyTo; + } + } + + public static class CookiesForYou { + public final int nrCookies; + + public CookiesForYou(int nrCookies) { + this.nrCookies = nrCookies; + } + } + public interface Action {} private static Behavior<Action> childInitial = Behaviors.ignore(); @@ -225,6 +253,24 @@ public class BehaviorTestKitTest extends JUnitSuite { context.getLog().info(message.what); return Behaviors.same(); }) + .onMessage( + AskForCookiesFrom.class, + message -> { + context.ask( + CookiesForYou.class, + message.distributor, + Duration.ofSeconds(10), + (ActorRef<CookiesForYou> ref) -> new GiveMeCookies(6, ref), + (response, throwable) -> { + if (response != null) { + return new Log( + "Got " + response.nrCookies + " cookies from distributor"); + } else { + return new Log("Failed to get cookies: " + throwable.getMessage()); + } + }); + return Behaviors.same(); + }) .build(); }); @@ -389,4 +435,21 @@ public class BehaviorTestKitTest extends JUnitSuite { () -> {}); assertNotNull(timerScheduled); } + + @Test + public void reifyAskAsEffect() { + BehaviorTestKit<Command> test = BehaviorTestKit.create(behavior); + TestInbox<CookieDistributorCommand> cdInbox = TestInbox.create(); + + test.run(new AskForCookiesFrom(cdInbox.getRef())); + + Effect expectedEffect = + Effects.askInitiated( + cdInbox.getRef(), Duration.ofSeconds(10), CookiesForYou.class, Command.class); + Effect.AskInitiated<?, ?, ?> actualEffect = test.expectEffectClass(Effect.AskInitiated.class); + + assertEquals(actualEffect, expectedEffect); + + // Other functionality is tested in the scaladsl + } } diff --git a/actor-testkit-typed/src/test/scala/docs/org/apache/pekko/actor/testkit/typed/scaladsl/SyncTestingExampleSpec.scala b/actor-testkit-typed/src/test/scala/docs/org/apache/pekko/actor/testkit/typed/scaladsl/SyncTestingExampleSpec.scala index 3d9c880c95..9f9e8aaf9a 100644 --- a/actor-testkit-typed/src/test/scala/docs/org/apache/pekko/actor/testkit/typed/scaladsl/SyncTestingExampleSpec.scala +++ b/actor-testkit-typed/src/test/scala/docs/org/apache/pekko/actor/testkit/typed/scaladsl/SyncTestingExampleSpec.scala @@ -22,8 +22,12 @@ import pekko.actor.testkit.typed.scaladsl.TestInbox import pekko.actor.typed._ import pekko.actor.typed.scaladsl._ import com.typesafe.config.ConfigFactory +import org.apache.pekko.util.Timeout import org.slf4j.event.Level +import scala.concurrent.duration.DurationInt +import scala.util.{ Failure, Success } + //#imports import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -44,6 +48,9 @@ object SyncTestingExampleSpec { case object SayHelloToAnonymousChild extends Command case class SayHello(who: ActorRef[String]) extends Command case class LogAndSayHello(who: ActorRef[String]) extends Command + case class AskAQuestion(who: ActorRef[Question]) extends Command + case class GotAnAnswer(answer: String, from: ActorRef[Question]) extends Command + case class NoAnswerFrom(whom: ActorRef[Question]) extends Command def apply(): Behaviors.Receive[Command] = Behaviors.receivePartial { case (context, CreateChild(name)) => @@ -67,7 +74,24 @@ object SyncTestingExampleSpec { context.log.info("Saying hello to {}", who.path.name) who ! "hello" Behaviors.same + case (context, AskAQuestion(who)) => + implicit val timeout: Timeout = 10.seconds + context.ask[Question, Answer](who, Question("do you know who I am?", _)) { + case Success(answer) => GotAnAnswer(answer.a, who) + case Failure(_) => NoAnswerFrom(who) + } + Behaviors.same + case (context, GotAnAnswer(answer, from)) => + context.log.info2("Got an answer [{}] from {}", answer, from) + Behaviors.same + case (context, NoAnswerFrom(from)) => + context.log.info("Did not get an answer from {}", from) + Behaviors.same } + + // Included in Hello for brevity + case class Question(q: String, replyTo: ActorRef[Answer]) + case class Answer(a: String) // #under-test } @@ -152,6 +176,53 @@ class SyncTestingExampleSpec extends AnyWordSpec with Matchers { // #test-check-logging } + "support the contextual ask pattern" in { + // #test-contextual-ask + val testKit = BehaviorTestKit(Hello()) + val askee = TestInbox[Hello.Question]() + testKit.run(Hello.AskAQuestion(askee.ref)) + + // The ask message is sent and can be inspected via the TestInbox + // note that the "replyTo" address is not directly predictable + val question = askee.receiveMessage() + + // The particulars of the `context.ask` call are captured as an Effect + val effect = testKit.expectEffectType[AskInitiated[Hello.Question, Hello.Answer, Hello.Command]] + + testKit.clearLog() + + // The returned effect can be used to complete or time-out the ask at most once + effect.respondWith(Hello.Answer("I think I met you somewhere, sometime")) + // (since we completed the ask, timing out is commented out) + // effect.timeout() + + // Completing/timing-out the ask is processed synchronously + testKit.logEntries().size shouldBe 1 + + // The message (including the synthesized "replyTo" address) can be inspected from the effect + val sentQuestion = effect.askMessage + + // The response adaptation can be tested as many times as you want without completing the ask + val response1 = effect.adaptResponse(Hello.Answer("No. Who are you?")) + val response2 = effect.adaptResponse(Hello.Answer("Hey Joe!")) + + // ... as can the message sent on a timeout + val timeoutResponse = effect.adaptTimeout + + // The response timeout can be inspected + val responseTimeout = effect.responseTimeout + // #test-contextual-ask + + // pro-forma assertions to satisfy warn-unused while following the pattern in this spec of not + // using ScalaTest matchers in code exposed through paradox + question shouldNot be(null) + sentQuestion shouldNot be(null) + response1 shouldNot be(null) + response2 shouldNot be(null) + timeoutResponse shouldNot be(null) + responseTimeout shouldNot be(null) + } + "has access to the provided config" in { val conf = BehaviorTestKit.ApplicationTestConfig.withFallback(ConfigFactory.parseString("test.secret=shhhhh")) diff --git a/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala b/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala index d5497952b1..844c1cee6f 100644 --- a/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala +++ b/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala @@ -26,6 +26,7 @@ import pekko.actor.testkit.typed.scaladsl.BehaviorTestKitSpec.Parent._ import pekko.actor.typed.{ ActorRef, Behavior, Props, Terminated } import pekko.actor.typed.receptionist.{ Receptionist, ServiceKey } import pekko.actor.typed.scaladsl.Behaviors +import pekko.util.Timeout import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -62,6 +63,7 @@ object BehaviorTestKitSpec { extends Command case class CancelScheduleCommand(key: Any) extends Command case class IsTimerActive(key: Any, replyTo: ActorRef[Boolean]) extends Command + case class AskForCookiesFrom(distributor: ActorRef[CookieDistributor.Command]) extends Command val init: Behavior[Command] = Behaviors.withTimers { timers => Behaviors @@ -150,6 +152,19 @@ object BehaviorTestKitSpec { case IsTimerActive(key, replyTo) => replyTo ! timers.isTimerActive(key) Behaviors.same + case AskForCookiesFrom(distributor) => + import CookieDistributor.{ CookiesForYou, GiveMeCookies } + + implicit val timeout: Timeout = 10.seconds + val randomNumerator = scala.util.Random.nextInt(13) + val randomDenominator = 1 + scala.util.Random.nextInt(randomNumerator + 1) + val nrCookies = randomNumerator / randomDenominator + + context.ask[GiveMeCookies, CookiesForYou](distributor, GiveMeCookies(nrCookies, _)) { + case scala.util.Success(cfy) => Log(s"Got ${cfy.nrCookies} cookies from distributor") + case scala.util.Failure(ex) => Log(s"Failed to get cookies: ${ex.getMessage}") + } + Behaviors.same case unexpected => throw new RuntimeException(s"Unexpected command: $unexpected") } @@ -175,6 +190,13 @@ object BehaviorTestKitSpec { } + object CookieDistributor { + sealed trait Command + + case class GiveMeCookies(nrCookies: Int, replyTo: ActorRef[CookiesForYou]) extends Command + + case class CookiesForYou(nrCookies: Int) + } } class BehaviorTestKitSpec extends AnyWordSpec with Matchers with LogCapturing { @@ -508,4 +530,152 @@ class BehaviorTestKitSpec extends AnyWordSpec with Matchers with LogCapturing { testkit.expectEffect(Effect.TimerCancelled("abc")) } } + + "BehaviorTestKit's ask" must { + "reify the ask for inspection" in { + import BehaviorTestKitSpec.CookieDistributor + import CookieDistributor.CookiesForYou + + val testKit = BehaviorTestKit[Parent.Command](Parent.init) + val cdInbox = TestInbox[CookieDistributor.Command]() + + testKit.run(AskForCookiesFrom(cdInbox.ref)) + + val effect = + testKit.expectEffectType[Effect.AskInitiated[CookieDistributor.Command, CookiesForYou, Parent.Command]] + + effect shouldEqual Effects.askInitiated(cdInbox.ref, 10.seconds, classOf[CookiesForYou]) + cdInbox.receiveMessage() shouldBe effect.askMessage + + val successResponse = effect.adaptResponse(CookiesForYou(10)) + successResponse shouldBe a[Log] + successResponse.asInstanceOf[Log].what should startWith("Got 10 cookies") + + val timeoutResponse = effect.adaptTimeout + timeoutResponse shouldBe a[Log] + timeoutResponse.asInstanceOf[Log].what should startWith("Failed to get cookies: Ask timed out on [") + } + + "allow the ask to be completed with success" in { + import BehaviorTestKitSpec.CookieDistributor + import CookieDistributor.CookiesForYou + + val testKit = BehaviorTestKit[Parent.Command](Parent.init) + val cdInbox = TestInbox[CookieDistributor.Command]() + + testKit.run(AskForCookiesFrom(cdInbox.ref)) + + cdInbox.hasMessages shouldBe true + + val effect = + testKit.expectEffectType[Effect.AskInitiated[CookieDistributor.Command, CookiesForYou, Parent.Command]] + + effect.askMessage shouldBe a[CookieDistributor.GiveMeCookies] + val cookiesRequested = effect.askMessage.asInstanceOf[CookieDistributor.GiveMeCookies].nrCookies + val cookiesGiven = scala.util.Random.nextInt(cookiesRequested + 1) + effect.respondWith(CookiesForYou(cookiesGiven)) + + testKit.selfInbox().hasMessages shouldBe false + val logEntries = testKit.logEntries() + testKit.clearLog() + logEntries.size shouldBe 1 + logEntries.foreach { log => + log.message shouldBe s"Got ${cookiesGiven} cookies from distributor" + } + } + + "allow the ask to be manually timed out" in { + import BehaviorTestKitSpec.CookieDistributor + import CookieDistributor.CookiesForYou + + val testKit = BehaviorTestKit[Parent.Command](Parent.init) + val cdInbox = TestInbox[CookieDistributor.Command]() + + testKit.run(AskForCookiesFrom(cdInbox.ref)) + + cdInbox.hasMessages shouldBe true + + val effect = + testKit.expectEffectType[Effect.AskInitiated[CookieDistributor.Command, CookiesForYou, Parent.Command]] + + effect.askMessage shouldBe a[CookieDistributor.GiveMeCookies] + + effect.timeout() + + testKit.selfInbox().hasMessages shouldBe false + val logEntries = testKit.logEntries() + testKit.clearLog() + logEntries.size shouldBe 1 + logEntries.foreach { log => + log.message should startWith("Failed to get cookies: Ask timed out on [") + } + } + + "not allow a completed ask to be completed or timed out again" in { + import BehaviorTestKitSpec.CookieDistributor + import CookieDistributor.CookiesForYou + + val testKit = BehaviorTestKit[Parent.Command](Parent.init) + val cdInbox = TestInbox[CookieDistributor.Command]() + + testKit.run(AskForCookiesFrom(cdInbox.ref)) + + cdInbox.hasMessages shouldBe true + + val effect = + testKit.expectEffectType[Effect.AskInitiated[CookieDistributor.Command, CookiesForYou, Parent.Command]] + + effect.respondWith(CookiesForYou(0)) + + an[IllegalStateException] shouldBe thrownBy { + effect.respondWith(CookiesForYou(1)) + } + + an[IllegalStateException] shouldBe thrownBy { + effect.timeout() + } + + // Only the first response should have a log + val logEntries = testKit.logEntries() + logEntries.size shouldBe 1 + logEntries.head.message should startWith("Got 0 cookies from distributor") + + testKit.hasEffects() shouldBe false + testKit.selfInbox().hasMessages shouldBe false + } + + "not allow a timed-out ask to be completed or timed out again" in { + import BehaviorTestKitSpec.CookieDistributor + import CookieDistributor.CookiesForYou + + val testKit = BehaviorTestKit[Parent.Command](Parent.init) + val cdInbox = TestInbox[CookieDistributor.Command]() + + testKit.run(AskForCookiesFrom(cdInbox.ref)) + + cdInbox.hasMessages shouldBe true + + val effect = + testKit.expectEffectType[Effect.AskInitiated[CookieDistributor.Command, CookiesForYou, Parent.Command]] + + effect.timeout() + + val logEntries = testKit.logEntries() + testKit.clearLog() + logEntries.size shouldBe 1 + logEntries.head.message should startWith("Failed to get cookies: Ask timed out on [") + + an[IllegalStateException] shouldBe thrownBy { + effect.respondWith(CookiesForYou(1)) + } + + an[IllegalStateException] shouldBe thrownBy { + effect.timeout() + } + + testKit.logEntries() shouldBe empty + testKit.hasEffects() shouldBe false + testKit.selfInbox().hasMessages shouldBe false + } + } } diff --git a/docs/src/main/paradox/typed/testing-sync.md b/docs/src/main/paradox/typed/testing-sync.md index f496bb76d9..3e9ab222f7 100644 --- a/docs/src/main/paradox/typed/testing-sync.md +++ b/docs/src/main/paradox/typed/testing-sync.md @@ -25,6 +25,7 @@ The following demonstrates how to test: * Spawning child actors anonymously * Sending a message either as a reply or to another actor * Sending a message to a child actor +* Asking via the `ActorContext` The examples below require the following imports: @@ -102,6 +103,15 @@ Scala Java : @@snip [SyncTestingExampleTest.java](/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java) { #test-child-message-anonymous } +An @ref:[ask via `ActorContext`](interaction-patterns.md#request-response-with-ask-between-two-actors) can be tested with the assistance of the @apidoc[actor.testkit.typed.Effect$AskInitiated] `Effect`. The request message is sent to the target recipient and can be obtained from the `AskInitiated`. The interaction may be completed by calling `respondWith` or `timeout` on the `AskInitiated`, and the transformation of the response or timeout into the requestor's protocol may also be test [...] + +Scala +: @@snip[SyncTestingExampleSpec.scala](/actor-testkit-typed/src/test/scala/docs/org/apache/pekko/actor/testkit/typed/scaladsl/SyncTestingExampleSpec.scala) { #test-contextual-ask } + +Java +: @@snip[SyncTestingExampleTest.java](/actor-testkit-typed/src/test/java/jdocs/org/apache/pekko/actor/testkit/typed/javadsl/SyncTestingExampleTest.java) { #test-contextual-ask } + + ### Testing other effects The @apidoc[BehaviorTestKit] keeps track other effects you can verify, look at the sub-classes of @apidoc[actor.testkit.typed.Effect] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
