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.