1. Overview
Akka is a convenient framework or toolkit for building reactive, concurrent, and distributed applications on the JVM. It is based on the reactive manifesto, and therefore it is event-driven, resilient, scalable, and responsive.
Actors are the basic building block of Akka, which represents small processing units with small memory footprints that can only communicate by exchanging messages.
In this tutorial, we’ll look at how we can test Actors to ensure that they behave as expected.
2. Test Configuration
We’ll be using Akka typed instead of the regular classic Actors as recommended by the Akka team.
To set up our test suite, we will need to add the test dependencies to our build.sbt:
val AkkaVersion = "2.8.0"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % Test
libraryDependencies += "org.scalatest" %% "scalatest" % "3.1.4" % Test
AkkaTestKit is best used with ScalaTest as recommended by the Akka team.
AkkaTestKit creates an instance of ActorTestKit which provides access to:
- An ActorSystem
- Methods for spawning Actors.
- A method to shut down the ActorSystem from the test suite
Here’s a simple test configuration that provides the testkit:
class TestService
extends AnyWordSpec
with BeforeAndAfterAll
with Matchers {
val testKit = ActorTestKit()
implicit val system = testKit.system
override def afterAll(): Unit = testKit.shutdownTestKit()
}
With this, we can now start writing our tests by extending the class. We also had to override the afterAll method, which is responsible for shutting down the ActorSystem.
3. Testing Patterns
Let’s imagine we design a simple greeting Actor program where we send a greeting, and the Actor simply responds with the same message as shown:
object Greeter {
case class Sent(greeting: String, self: ActorRef[Received])
case class Received(greeting: String)
def apply(): Behavior[Sent] = Behaviors.receiveMessage {
case Sent(greeting, recipient) =>
recipient ! Received(greeting)
Behaviors.same
}
}
We can test this simple program by using our testkit to spawn this Actor and confirm that we receive the same message we send to it.
The testkit provides us with a test probe. This probe receives messages from the Actor under test. This probe ensures unwanted messages don’t come in:
class GreeterTest extends TestService {
import scala.concurrent.duration._
val greeting = "Hello there"
val sender = testKit.spawn(Greeter(), "greeter")
val probe = testKit.createTestProbe[Greeter.GreetingResponse]()
sender ! Greeter.GreetingRequest(greeting, probe.ref)
probe.expectMessage(Greeter.GreetingResponse(greeting))
probe.expectNoMessage(50.millis)
}
A probe representing a typed Actor is used here to ensure that we receive the expected message and nothing more.
A test probe is essentially a queryable mailbox that can be used in place of an Actor, and the received messages can then be asserted.
For a more involved example, let’s use an Actor to implement a traffic light system and ensure that the state is always preserved and correct:
object TrafficLight {
sealed trait Signal
object Signal {
case object RED extends Signal
case object YELLOW extends Signal
case object GREEN extends Signal
}
sealed trait SignalCommand
object SignalCommand {
case class ChangeSignal(recipient : ActorRef[CurrentSignal]) extends SignalCommand
case class GetSignal (recipient : ActorRef[CurrentSignal]) extends SignalCommand
}
case class CurrentSignal(signal : Signal)
import Signal._
def apply() : Behavior[SignalCommand] = Behaviors.setup{_ =>
var state : Signal = RED
Behaviors.receiveMessage {
case ChangeSignal(recipient) =>
val nextState = state match {
case RED => YELLOW
case YELLOW => GREEN
case GREEN => RED
}
state = nextState
recipient ! CurrentSignal(nextState)
Behaviors.same
case GetSignal(recipient) =>
recipient ! CurrentSignal(state)
Behaviors.same
}
}
}
In this simple Actor, its initial state is RED. It can change its state or return its current state depending on the message it receives. We need to test this Actor to ensure that it changes state as expected. We also need to test that we don’t receive any other message afterward:
class TrafficLightTest extends TestService {
import scala.concurrent.duration._
val sender = testKit.spawn(TrafficLight(), "traffic")
val probe = testKit.createTestProbe[TrafficLight.CurrentSignal]()
sender ! TrafficLight.SignalCommand.GetSignal(probe.ref)
probe.expectMessage(TrafficLight.CurrentSignal(TrafficLight.Signal.RED))
probe.expectNoMessage(50.millis)
sender ! TrafficLight.SignalCommand.ChangeSignal(probe.ref)
probe.expectMessage(TrafficLight.CurrentSignal(TrafficLight.Signal.YELLOW))
probe.expectNoMessage(50.millis)
sender ! TrafficLight.SignalCommand.ChangeSignal(probe.ref)
probe.expectMessage(TrafficLight.CurrentSignal(TrafficLight.Signal.GREEN))
probe.expectNoMessage(50.millis)
sender ! TrafficLight.SignalCommand.ChangeSignal(probe.ref)
probe.expectMessage(TrafficLight.CurrentSignal(TrafficLight.Signal.RED))
probe.expectNoMessage(50.millis)
}
In this test, we can see that the Actor changes its state as expected.
We can also test the ask pattern in Actors:
class TrafficLightTestFut extends TestService {
import scala.concurrent.duration._
val sender = testKit.spawn(TrafficLight(), "traffic")
val duration = 300.millis
implicit val timeout = Timeout(duration)
val signalFut = sender.ask( replyTo => TrafficLight.SignalCommand.GetSignal(replyTo) )
val signal = Await.result(signalFut, duration)
assert(signal == CurrentSignal(RED))
}
The traffic Actor is queried for the current signal and returns the correct signal.
4. Conclusion
In this article, we’ve seen how to set up typed Actor test configurations as well as write and observe simple test patterns. There are so many ways to write our tests, and we must understand the basic foundation and building blocks of how the tests work.
As usual, the source code can be found over on GitHub.