This is an automated email from the ASF dual-hosted git repository.
hepin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new 51392022ab feat: Add effectful asking support in typed BehaviorTestKit
(#2450)
51392022ab is described below
commit 51392022aba5451976b7be5a79b387a261c4aebf
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Sat Nov 8 18:35:17 2025 +0800
feat: Add effectful asking support in typed BehaviorTestKit (#2450)
---
.../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..0c3df99008 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,18 +13,23 @@
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 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
+import pekko.util.Timeout
/**
* INTERNAL API
@@ -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..34a5e4d84f 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 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]