Akka Actor Testing: TestKit, TestProbe, and akka-testkit Patterns

Akka Actor Testing: TestKit, TestProbe, and akka-testkit Patterns

Akka actors are concurrent by nature — they communicate asynchronously through message passing, which makes traditional unit testing approaches fail. akka-testkit provides TestKit and TestProbe to write deterministic tests for actor behavior: message routing, state transitions, supervision strategies, and timing.

This guide covers the classic Akka (untyped) testkit and Akka Typed's BehaviorTestKit and ActorTestKit.

Setup

// build.sbt
libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-actor"   % "2.8.0",
  "com.typesafe.akka" %% "akka-testkit" % "2.8.0" % Test,
  "org.scalatest"     %% "scalatest"    % "3.2.17" % Test
)

For Akka Typed:

libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-actor-typed"         % "2.8.0",
  "com.typesafe.akka" %% "akka-actor-testkit-typed" % "2.8.0" % Test
)

TestKit Basics

TestKit creates an ActorSystem for testing and provides the testActor — an actor you can send messages to and receive replies from:

import akka.actor.{ActorSystem, Props}
import akka.testkit.{ImplicitSender, TestKit}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.wordspec.AnyWordSpecLike

class EchoActorSpec extends TestKit(ActorSystem("EchoSpec"))
  with ImplicitSender
  with AnyWordSpecLike
  with BeforeAndAfterAll {

  override def afterAll(): Unit = TestKit.shutdownSystem(system)

  "EchoActor" should {
    "echo messages back to sender" in {
      val echo = system.actorOf(EchoActor.props)
      echo ! "hello"
      expectMsg("hello")
    }

    "handle multiple messages in order" in {
      val echo = system.actorOf(EchoActor.props)
      echo ! "first"
      echo ! "second"
      expectMsg("first")
      expectMsg("second")
    }
  }
}

ImplicitSender sets testActor as the implicit sender for all ! sends. Replies arrive at testActor and are retrieved with expectMsg.

Assertion Methods

// Exact match — waits up to 3 seconds by default
expectMsg("hello")
expectMsg(3.seconds, "hello")  // custom timeout

// Type match
expectMsgType[UserCreated]
val event = expectMsgType[UserCreated]
assert(event.userId > 0)

// Predicate match
expectMsgPF() {
  case UserCreated(id, _) if id > 0 => id
}

// No message expected
expectNoMessage(200.milliseconds)

// Any message
val msg = receiveOne(1.second)

// Multiple messages in any order
receiveN(3)
expectMsgAllOf("a", "b", "c")  // all three, any order

// Ignore unwanted messages and wait for expected
fishForMessage(3.seconds) {
  case "target" => true
  case _        => false
}

TestProbe

TestProbe creates additional "fake actors" to stand in for collaborators:

class OrderProcessorSpec extends TestKit(ActorSystem("OrderSpec"))
  with ImplicitSender
  with AnyWordSpecLike
  with BeforeAndAfterAll {

  override def afterAll(): Unit = TestKit.shutdownSystem(system)

  "OrderProcessor" should {
    "send confirmation to payment service" in {
      val paymentService = TestProbe("payment")
      val inventory      = TestProbe("inventory")

      val processor = system.actorOf(
        OrderProcessor.props(paymentService.ref, inventory.ref)
      )

      processor ! PlaceOrder("item-1", quantity = 2, customerId = 42)

      // Verify messages sent to collaborators
      inventory.expectMsg(CheckStock("item-1", 2))
      inventory.reply(StockAvailable("item-1", 2))

      paymentService.expectMsgType[ChargeCard]
      paymentService.reply(PaymentConfirmed("tx-123"))

      expectMsg(OrderConfirmed("tx-123"))
    }
  }
}

TestProbe is the key tool for verifying actor collaboration. It records all received messages and provides the same expectMsg API as TestKit.

Testing Actor State with Become/Unbecome

For actors that change behavior with context.become:

class TrafficLightSpec extends TestKit(ActorSystem("TrafficSpec"))
  with ImplicitSender with AnyWordSpecLike with BeforeAndAfterAll {

  override def afterAll(): Unit = TestKit.shutdownSystem(system)

  "TrafficLight" should {
    "transition from red to green" in {
      val light = system.actorOf(TrafficLight.props)

      light ! GetState
      expectMsg(Red)

      light ! Switch
      light ! GetState
      expectMsg(Green)

      light ! Switch
      light ! GetState
      expectMsg(Yellow)
    }
  }
}

Testing Supervision

Supervision strategies determine what happens when child actors fail. Test with TestProbe watching the actor:

"supervisor" should {
  "restart child on ArithmeticException" in {
    val supervisor = system.actorOf(Supervisor.props)

    val probe = TestProbe()

    supervisor ! CreateWorker("worker-1")
    val worker = expectMsgType[ActorRef]

    probe.watch(worker)

    worker ! Divide(10, 0)  // causes ArithmeticException

    // Worker is restarted, not terminated
    probe.expectNoMessage(500.milliseconds)
    probe.expectNotTerminated(worker)

    // Worker is back and functional
    worker ! Divide(10, 2)
    expectMsg(Result(5))
  }

  "stop child on unhandled exception" in {
    val supervisor = system.actorOf(Supervisor.props)
    val probe = TestProbe()

    supervisor ! CreateWorker("worker-2")
    val worker = expectMsgType[ActorRef]
    probe.watch(worker)

    worker ! FatalOperation

    probe.expectTerminated(worker)
  }
}

Timed Assertions and Scheduling

"scheduler" should {
  "fire heartbeat every second" in {
    val probe = TestProbe()
    val scheduler = system.actorOf(HeartbeatActor.props(probe.ref))

    probe.expectMsg(2.seconds, Heartbeat)
    probe.expectMsg(2.seconds, Heartbeat)
    probe.expectMsg(2.seconds, Heartbeat)
  }
}

For deterministic time control, use akka.testkit.CallingThreadDispatcher or TestScheduler.

Akka Typed: BehaviorTestKit

Akka Typed provides BehaviorTestKit for synchronous, single-threaded behavior testing:

import akka.actor.testkit.typed.scaladsl.BehaviorTestKit
import akka.actor.testkit.typed.scaladsl.TestInbox

class CounterBehaviorSpec extends AnyWordSpec {
  "Counter behavior" should {
    "increment on Increment message" in {
      val testKit = BehaviorTestKit(Counter())
      val inbox   = TestInbox[Int]()

      testKit.run(Counter.Increment)
      testKit.run(Counter.Increment)
      testKit.run(Counter.GetCount(inbox.ref))

      inbox.expectMessage(2)
    }

    "spawn child actor on CreateChild" in {
      val testKit = BehaviorTestKit(ParentActor())

      testKit.run(ParentActor.CreateChild("worker"))

      // Verify child was spawned
      assert(testKit.hasEffects())
      testKit.expectEffectType[Effects.Spawned[_]]
    }
  }
}

BehaviorTestKit is synchronous — all messages process immediately. Use it for pure behavior logic. For testing actual concurrency, use ActorTestKit.

Akka Typed: ActorTestKit

ActorTestKit creates a real ActorSystem for async testing:

import akka.actor.testkit.typed.scaladsl.ActorTestKit
import org.scalatest.BeforeAndAfterAll

class UserRegistrySpec extends AnyWordSpec with BeforeAndAfterAll {
  val testKit = ActorTestKit()

  override def afterAll(): Unit = testKit.shutdownTestKit()

  "UserRegistry" should {
    "register and retrieve users" in {
      val registry = testKit.spawn(UserRegistry())
      val probe    = testKit.createTestProbe[UserRegistry.Response]()

      registry ! UserRegistry.Register("alice@example.com", probe.ref)
      val created = probe.receiveMessage()
      assert(created.isInstanceOf[UserRegistry.Registered])

      registry ! UserRegistry.GetUser(created.asInstanceOf[UserRegistry.Registered].id, probe.ref)
      val user = probe.receiveMessage().asInstanceOf[UserRegistry.UserFound]
      assert(user.email == "alice@example.com")
    }
  }
}

testKit.createTestProbe[T]() is the Typed equivalent of TestProbe.

Cluster and Persistence Testing

For Akka Persistence actors, use in-memory persistence:

// application.conf (test)
akka.persistence.journal.plugin = "akka.persistence.journal.inmem"
akka.persistence.snapshot-store.plugin = "akka.persistence.snapshot-store.local"
akka.persistence.snapshot-store.local.dir = "target/test-snapshots"
"PersistentCounter" should {
  "recover state after restart" in {
    val counter = system.actorOf(PersistentCounter.props("counter-1"))

    counter ! Increment
    counter ! Increment
    counter ! GetCount
    expectMsg(Count(2))

    // Kill and recreate with same persistence ID
    system.stop(counter)
    Thread.sleep(100)

    val recovered = system.actorOf(PersistentCounter.props("counter-1"))
    recovered ! GetCount
    expectMsg(Count(2))  // state recovered from journal
  }
}

Continuous Testing Beyond Unit Tests

akka-testkit verifies actor logic in isolation. For distributed Akka cluster behavior under real network conditions, HelpMeTest can run end-to-end scenarios against your live deployment — validating that message routing, supervision, and persistence work correctly in production.

Summary

akka-testkit provides the tools to write deterministic tests for inherently concurrent actor systems. TestKit gives you an actor-based test harness; TestProbe stands in for collaborators; BehaviorTestKit enables synchronous behavior testing for Akka Typed. Use expectMsg and its variants to assert on message sequences, and watch/expectTerminated to verify supervision outcomes.

Test actor behavior in isolation first, then integration-test the actor graph with real message flows. This layered approach catches both individual actor bugs and collaboration issues.

Read more