Skip to content

Commit 399d6b0

Browse files
authored
feat: Add asking support to BehaviorTestKit (#2453) (#2463)
(cherry picked from commit 82692c8)
1 parent 8fb274c commit 399d6b0

File tree

9 files changed

+438
-25
lines changed

9 files changed

+438
-25
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# Add ask behavior methods
19+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.actor.testkit.typed.javadsl.BehaviorTestKit.runAsk")
20+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.actor.testkit.typed.javadsl.BehaviorTestKit.runAskWithStatus")
21+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.actor.testkit.typed.scaladsl.BehaviorTestKit.runAsk")
22+
ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.actor.testkit.typed.scaladsl.BehaviorTestKit.runAskWithStatus")

actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/BehaviorTestKitImpl.scala

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import pekko.actor.typed.internal.{ AdaptMessage, AdaptWithRegisteredMessageAdap
2929
import pekko.actor.typed.receptionist.Receptionist
3030
import pekko.actor.typed.scaladsl.Behaviors
3131
import pekko.annotation.InternalApi
32+
import pekko.japi.function.{ Function => JFunction }
33+
import pekko.pattern.StatusReply
34+
import pekko.util.OptionVal
3235
import pekko.util.ccompat.JavaConverters._
3336

3437
/**
@@ -61,6 +64,27 @@ private[pekko] final class BehaviorTestKitImpl[T](
6164
// execute any future tasks scheduled in Actor's constructor
6265
runAllTasks()
6366

67+
override def runAsk[Res](f: ActorRef[Res] => T): ReplyInboxImpl[Res] = {
68+
val replyToInbox = TestInboxImpl[Res]("replyTo")
69+
70+
run(f(replyToInbox.ref))
71+
new ReplyInboxImpl(OptionVal(replyToInbox))
72+
}
73+
74+
override def runAsk[Res](messageFactory: JFunction[ActorRef[Res], T]): ReplyInboxImpl[Res] =
75+
runAsk(messageFactory.apply _)
76+
77+
override def runAskWithStatus[Res](f: ActorRef[StatusReply[Res]] => T): StatusReplyInboxImpl[Res] = {
78+
val replyToInbox = TestInboxImpl[StatusReply[Res]]("replyTo")
79+
80+
run(f(replyToInbox.ref))
81+
new StatusReplyInboxImpl(OptionVal(replyToInbox))
82+
}
83+
84+
override def runAskWithStatus[Res](
85+
messageFactory: JFunction[ActorRef[StatusReply[Res]], T]): StatusReplyInboxImpl[Res] =
86+
runAskWithStatus(messageFactory.apply _)
87+
6488
override def retrieveEffect(): Effect = context.effectQueue.poll() match {
6589
case null => NoEffects
6690
case x => x

actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/TestInboxImpl.scala

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import scala.annotation.tailrec
1919
import scala.collection.immutable
2020

2121
import org.apache.pekko
22-
import pekko.actor.ActorPath
22+
import pekko.actor.{ ActorPath, Address, RootActorPath }
2323
import pekko.actor.typed.ActorRef
2424
import pekko.annotation.InternalApi
25+
import pekko.pattern.StatusReply
26+
import pekko.util.OptionVal
2527

2628
/**
2729
* INTERNAL API
@@ -63,3 +65,120 @@ private[pekko] final class TestInboxImpl[T](path: ActorPath)
6365
@InternalApi private[pekko] def as[U]: TestInboxImpl[U] = this.asInstanceOf[TestInboxImpl[U]]
6466

6567
}
68+
69+
/**
70+
* INTERNAL API
71+
*/
72+
@InternalApi
73+
object TestInboxImpl {
74+
def apply[T](name: String): TestInboxImpl[T] = {
75+
new TestInboxImpl(address / name)
76+
}
77+
78+
private[pekko] val address = RootActorPath(Address("pekko.actor.typed.inbox", "anonymous"))
79+
}
80+
81+
/**
82+
* INTERNAL API
83+
*/
84+
@InternalApi
85+
private[pekko] final class ReplyInboxImpl[T](private var underlying: OptionVal[TestInboxImpl[T]])
86+
extends pekko.actor.testkit.typed.javadsl.ReplyInbox[T]
87+
with pekko.actor.testkit.typed.scaladsl.ReplyInbox[T] {
88+
89+
def receiveReply(): T =
90+
underlying match {
91+
case OptionVal.Some(testInbox) =>
92+
underlying = OptionVal.None
93+
testInbox.receiveMessage()
94+
95+
case _ => throw new AssertionError("Reply was already received")
96+
}
97+
98+
def expectReply(expectedReply: T): Unit =
99+
receiveReply() match {
100+
case matches if matches == expectedReply => ()
101+
case doesntMatch =>
102+
throw new AssertionError(s"Expected $expectedReply but received $doesntMatch")
103+
}
104+
105+
def expectNoReply(): ReplyInboxImpl[T] =
106+
underlying match {
107+
case OptionVal.Some(testInbox) if testInbox.hasMessages =>
108+
throw new AssertionError(s"Expected no reply, but ${receiveReply()} was received")
109+
110+
case OptionVal.Some(_) => this
111+
112+
case _ =>
113+
// already received the reply, so this expectation shouldn't even be made
114+
throw new AssertionError("Improper expectation of no reply: reply was already received")
115+
}
116+
117+
def hasReply: Boolean =
118+
underlying match {
119+
case OptionVal.Some(testInbox) => testInbox.hasMessages
120+
case _ => false
121+
}
122+
}
123+
124+
/**
125+
* INTERNAL API
126+
*/
127+
@InternalApi
128+
private[pekko] final class StatusReplyInboxImpl[T](private var underlying: OptionVal[TestInboxImpl[StatusReply[T]]])
129+
extends pekko.actor.testkit.typed.javadsl.StatusReplyInbox[T]
130+
with pekko.actor.testkit.typed.scaladsl.StatusReplyInbox[T] {
131+
132+
def receiveStatusReply(): StatusReply[T] =
133+
underlying match {
134+
case OptionVal.Some(testInbox) =>
135+
underlying = OptionVal.None
136+
testInbox.receiveMessage()
137+
138+
case _ => throw new AssertionError("Reply was already received")
139+
}
140+
141+
def receiveValue(): T =
142+
receiveStatusReply() match {
143+
case StatusReply.Success(v) => v.asInstanceOf[T]
144+
case err => throw new AssertionError(s"Expected a successful reply but received $err")
145+
}
146+
147+
def receiveError(): Throwable =
148+
receiveStatusReply() match {
149+
case StatusReply.Error(t) => t
150+
case success => throw new AssertionError(s"Expected an error reply but received $success")
151+
}
152+
153+
def expectValue(expectedValue: T): Unit =
154+
receiveValue() match {
155+
case matches if matches == expectedValue => ()
156+
case doesntMatch =>
157+
throw new AssertionError(s"Expected $expectedValue but received $doesntMatch")
158+
}
159+
160+
def expectErrorMessage(errorMessage: String): Unit =
161+
receiveError() match {
162+
case matches if matches.getMessage == errorMessage => ()
163+
case doesntMatch =>
164+
throw new AssertionError(s"Expected a throwable with message $errorMessage, but got ${doesntMatch.getMessage}")
165+
}
166+
167+
def expectNoReply(): StatusReplyInboxImpl[T] =
168+
underlying match {
169+
case OptionVal.Some(testInbox) if testInbox.hasMessages =>
170+
throw new AssertionError(s"Expected no reply, but ${receiveStatusReply()} was received")
171+
172+
case OptionVal.Some(_) => this
173+
174+
case _ =>
175+
// already received the reply, so this expectation shouldn't even be made
176+
throw new AssertionError("Improper expectation of no reply: reply was already received")
177+
}
178+
179+
def hasReply: Boolean =
180+
underlying match {
181+
case OptionVal.Some(testInbox) => testInbox.hasMessages
182+
case _ => false
183+
}
184+
}

actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKit.scala

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@
1313

1414
package org.apache.pekko.actor.testkit.typed.javadsl
1515

16+
import java.util.concurrent.ThreadLocalRandom
17+
18+
import scala.annotation.nowarn
19+
1620
import org.apache.pekko
1721
import pekko.actor.testkit.typed.internal.{ ActorSystemStub, BehaviorTestKitImpl }
1822
import pekko.actor.testkit.typed.{ CapturedLogEvent, Effect }
1923
import pekko.actor.typed.receptionist.Receptionist
2024
import pekko.actor.typed.{ ActorRef, Behavior, Signal }
2125
import pekko.annotation.{ ApiMayChange, DoNotInherit }
22-
import com.typesafe.config.Config
26+
import pekko.japi.function.{ Function => JFunction }
27+
import pekko.pattern.StatusReply
2328

24-
import java.util.concurrent.ThreadLocalRandom
29+
import com.typesafe.config.Config
2530

2631
object BehaviorTestKit {
2732

@@ -70,6 +75,56 @@ object BehaviorTestKit {
7075
@ApiMayChange
7176
abstract class BehaviorTestKit[T] {
7277

78+
/**
79+
* Constructs a message using the provided 'messageFactory' to inject a single-use "reply to"
80+
* [[akka.actor.typed.ActorRef]], and sends the constructed message to the behavior, recording any [[Effect]]s.
81+
*
82+
* The returned [[ReplyInbox]] allows the message sent to the "reply to" `ActorRef` to be asserted on.
83+
*
84+
* @since 1.3.0
85+
*/
86+
def runAsk[Res](messageFactory: JFunction[ActorRef[Res], T]): ReplyInbox[Res]
87+
88+
/**
89+
* The same as [[runAsk]], but with the response class specified. This improves type inference in Java
90+
* when asserting on the reply in the same statement as the `runAsk` as in:
91+
*
92+
* ```
93+
* testkit.runAsk(Done.class, DoSomethingCommand::new).expectReply(Done.getInstance());
94+
* ```
95+
*
96+
* If explicitly saving the [[ReplyInbox]] in a variable, the version without the class may be preferred.
97+
*
98+
* @since 1.3.0
99+
*/
100+
@nowarn("msg=never used") // responseClass is a pretend param to guide inference
101+
def runAsk[Res](responseClass: Class[Res], messageFactory: JFunction[ActorRef[Res], T]): ReplyInbox[Res] =
102+
runAsk(messageFactory)
103+
104+
/**
105+
* The same as [[runAsk]] but only for requests that result in a response of type [[akka.pattern.StatusReply]].
106+
*
107+
* @since 1.3.0
108+
*/
109+
def runAskWithStatus[Res](messageFactory: JFunction[ActorRef[StatusReply[Res]], T]): StatusReplyInbox[Res]
110+
111+
/**
112+
* The same as [[runAskWithStatus]], but with the response class specified. This improves type inference in
113+
* Java when asserting on the reply in the same statement as the `runAskWithStatus` as in:
114+
*
115+
* ```
116+
* testkit.runAskWithStatus(Done.class, DoSomethingWithStatusCommand::new).expectValue(Done.getInstance());
117+
* ```
118+
*
119+
* If explicitly saving the [[StatusReplyInbox]] in a variable, the version without the class may be preferred.
120+
*
121+
* @since 1.3.0
122+
*/
123+
@nowarn("msg=never used") // responseClass is a pretend param to guide inference
124+
def runAskWithStatus[Res](responseClass: Class[Res],
125+
messageFactory: JFunction[ActorRef[StatusReply[Res]], T]): StatusReplyInbox[Res] =
126+
runAskWithStatus(messageFactory)
127+
73128
/**
74129
* Requests the oldest [[Effect]] or [[pekko.actor.testkit.typed.javadsl.Effects.noEffects]] if no effects
75130
* have taken place. The effect is consumed, subsequent calls won't

actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/javadsl/TestInbox.scala

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import scala.collection.immutable
2020
import org.apache.pekko
2121
import pekko.actor.testkit.typed.internal.TestInboxImpl
2222
import pekko.actor.typed.ActorRef
23-
import pekko.annotation.DoNotInherit
23+
import pekko.annotation.{ ApiMayChange, DoNotInherit }
24+
import pekko.pattern.StatusReply
2425
import pekko.util.ccompat.JavaConverters._
2526

2627
object TestInbox {
@@ -76,3 +77,84 @@ abstract class TestInbox[T] {
7677

7778
// TODO expectNoMsg etc
7879
}
80+
81+
/**
82+
* Similar to an [[akka.actor.testkit.typed.javadsl.TestInbox]], but can only ever give access to a single message (a reply).
83+
*
84+
* Not intended for user creation: the [[akka.actor.testkit.typed.javadsl.BehaviorTestKit]] will provide these to
85+
* denote that at most a single reply is expected.
86+
*
87+
* @since 1.3.0
88+
*/
89+
@DoNotInherit
90+
@ApiMayChange
91+
trait ReplyInbox[T] {
92+
93+
/**
94+
* Get and remove the reply. Subsequent calls to `receiveReply`, `expectReply`, and `expectNoReply` will fail and `hasReplies`
95+
* will be false after calling this method
96+
*/
97+
def receiveReply(): T
98+
99+
/**
100+
* Assert and remove the message. Subsequent calls to `receiveReply`, `expectReply`, and `expectNoReply` will fail and `hasReplies`
101+
* will be false after calling this method
102+
*/
103+
def expectReply(expectedReply: T): Unit
104+
105+
def expectNoReply(): ReplyInbox[T]
106+
def hasReply: Boolean
107+
}
108+
109+
/**
110+
* A [[akka.actor.testkit.typed.javadsl.ReplyInbox]] which specially handles [[akka.pattern.StatusReply]].
111+
*
112+
* Note that there is no provided ability to expect a specific `Throwable`, as it's recommended to prefer
113+
* a string error message or to enumerate failures with specific types.
114+
*
115+
* Not intended for user creation: the [[akka.actor.testkit.typed.javadsl.BehaviorTestKit]] will provide these to
116+
* denote that at most a single reply is expected.
117+
*/
118+
@DoNotInherit
119+
@ApiMayChange
120+
trait StatusReplyInbox[T] {
121+
122+
/**
123+
* Get and remove the status reply. Subsequent calls to any `receive` or `expect` method will fail and `hasReply`
124+
* will be false after calling this method.
125+
*/
126+
def receiveStatusReply(): StatusReply[T]
127+
128+
/**
129+
* Get and remove the successful value of the status reply. This will fail if the status reply is an error.
130+
* Subsequent calls to any `receive` or `expect` method will fail and `hasReply` will be false after calling this
131+
* method.
132+
*/
133+
def receiveValue(): T
134+
135+
/**
136+
* Get and remove the error value of the status reply. This will fail if the status reply is a success.
137+
* Subsequent calls to any `receive` or `expect` method will fail and `hasReply` will be false after calling this
138+
* method.
139+
*/
140+
def receiveError(): Throwable
141+
142+
/**
143+
* Assert that the status reply is a success with this value and remove the status reply. Subsequent calls to any
144+
* `receive` or `expect` method will fail and `hasReply` will be false after calling this method.
145+
*/
146+
def expectValue(expectedValue: T): Unit
147+
148+
/**
149+
* Assert that the status reply is a failure with this error message and remove the status reply. Subsequent
150+
* calls to any `receive` or `expect` method will fail and `hasReply` will be false after calling this method.
151+
*/
152+
def expectErrorMessage(errorMessage: String): Unit
153+
154+
/**
155+
* Assert that this inbox has *never* received a reply.
156+
*/
157+
def expectNoReply(): StatusReplyInbox[T]
158+
159+
def hasReply: Boolean
160+
}

0 commit comments

Comments
 (0)