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]


Reply via email to