Akka TestKit: Testing Actors, FSMs, and Actor Lifecycles in Scala

Akka TestKit: Testing Actors, FSMs, and Actor Lifecycles in Scala

Akka TestKit provides specialised tools for testing actor-based systems: TestProbe intercepts messages, expectMsg asserts on received messages with configurable timeouts, and TestFSMRef exposes FSM internals for white-box testing. This post covers every layer from simple unit tests to complex multi-actor scenarios.

Testing actors is fundamentally different from testing ordinary objects. Actors communicate asynchronously, maintain internal state that's not directly accessible, and interact through a message-passing protocol. Akka TestKit was built specifically for this: it gives you synchronous-feeling assertions over inherently async behaviour, tools to inject test doubles into actor hierarchies, and control over timing without arbitrary Thread.sleep calls.

Setup

Add the TestKit dependency alongside your Akka version:

libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-actor"          % "2.8.5",
  "com.typesafe.akka" %% "akka-testkit"        % "2.8.5" % Test,
  "org.scalatest"     %% "scalatest"            % "3.2.18" % Test
)

Note: Akka 2.7+ requires a commercial licence for new projects. If you're on the open-source path, use Akka 2.6.x or migrate to Apache Pekko (org.apache.pekko), which is API-compatible.

Basic TestKit Setup

Every test class that uses TestKit needs an ActorSystem and must terminate it after the suite:

import akka.actor.{Actor, ActorSystem, Props}
import akka.testkit.{ImplicitSender, TestKit, TestProbe}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
import scala.concurrent.duration._

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

  override def afterAll(): Unit = {
    TestKit.shutdownActorSystem(system)
  }

  "EchoActor" should {
    "reply with the same message it receives" in {
      val echo = system.actorOf(Props[EchoActor]())
      echo ! "hello"
      expectMsg("hello")
    }
  }
}

class EchoActor extends Actor {
  def receive: Receive = {
    case msg => sender() ! msg
  }
}

ImplicitSender automatically sets the test actor as the implicit sender, so actor replies come back to testActor and you can call expectMsg directly on it.

TestProbe: The Core Tool

TestProbe is a synthetic actor you control. You send messages to real actors through a probe, or tell actors to report to a probe, then assert on what the probe receives.

"A WorkerActor" should {
  "notify the supervisor when a job completes" in {
    val supervisor = TestProbe()
    val worker = system.actorOf(
      Props(new WorkerActor(supervisor.ref))
    )

    worker ! ProcessJob("job-42")

    supervisor.expectMsgType[JobCompleted]
    supervisor.lastMessage.msg.asInstanceOf[JobCompleted].jobId should be("job-42")
  }

  "send a failure notification when the job errors" in {
    val supervisor = TestProbe()
    val worker = system.actorOf(
      Props(new WorkerActor(supervisor.ref))
    )

    worker ! ProcessJob("bad-job")

    val failure = supervisor.expectMsgType[JobFailed]
    failure.reason should include("bad-job")
  }
}

Multiple probes for multi-actor interactions

"A Router actor" should {
  "distribute work to both workers" in {
    val worker1 = TestProbe()
    val worker2 = TestProbe()
    val router = system.actorOf(
      Props(new RoundRobinRouter(List(worker1.ref, worker2.ref)))
    )

    router ! Task("a")
    router ! Task("b")

    worker1.expectMsgType[Task]
    worker2.expectMsgType[Task]
  }
}

Configuring Timeouts

expectMsg has a default timeout (usually 3 seconds from the akka.test.single-expect-default config). Override it per-assertion:

// Explicit per-call timeout
supervisor.expectMsg(5.seconds, JobCompleted("job-42"))

// Expect no message arrives within a window
probe.expectNoMessage(200.millis)

// Verify a message arrives within a deadline but check its content too
val msg = probe.expectMsgPF(3.seconds) {
  case JobCompleted(id) if id.startsWith("job-") => id
}
msg should startWith("job-")

Configure defaults in src/test/resources/application.conf:

akka.test {
  single-expect-default = 5s
  default-timeout = 10s
  filter-leeway = 3s
}

Testing with within Blocks

within asserts that a block of code completes within a time window. It's more precise than per-message timeouts when you're testing multiple assertions:

"respond to a batch of messages within 2 seconds" in {
  val worker = system.actorOf(Props[BatchWorker]())

  within(2.seconds) {
    (1 to 5).foreach(i => worker ! Work(i))
    (1 to 5).foreach(_ => expectMsgType[WorkDone])
  }
}

Combine within with expectMsgAllOf for order-independent batch assertions:

within(3.seconds) {
  worker ! Work(1)
  worker ! Work(2)
  worker ! Work(3)
  expectMsgAllOf(WorkDone(1), WorkDone(2), WorkDone(3))
}

Testing Actor State: TestActorRef

For white-box unit testing where you need to inspect the actor's internal state directly, use TestActorRef. It runs the actor synchronously on the calling thread:

import akka.testkit.TestActorRef

"CounterActor" should {
  "increment its internal counter" in {
    val actorRef = TestActorRef[CounterActor]
    val actor = actorRef.underlyingActor

    actorRef ! Increment
    actorRef ! Increment
    actorRef ! Increment

    actor.count should be(3)
  }
}

class CounterActor extends Actor {
  var count = 0

  def receive: Receive = {
    case Increment => count += 1
    case GetCount  => sender() ! count
  }
}

TestActorRef processes messages synchronously, so there's no need for expectMsg — assertions run immediately after the ! call.

Testing Finite State Machines

Akka's FSM trait is notoriously tricky to test. TestFSMRef gives you read access to the current state and state data:

import akka.testkit.TestFSMRef

sealed trait ConnectionState
case object Disconnected extends ConnectionState
case object Connecting   extends ConnectionState
case object Connected    extends ConnectionState

sealed trait ConnectionData
case object NoData extends ConnectionData
case class  SessionData(token: String) extends ConnectionData

class ConnectionFSM extends FSM[ConnectionState, ConnectionData] {
  startWith(Disconnected, NoData)

  when(Disconnected) {
    case Event(Connect(host), NoData) =>
      goto(Connecting) using NoData
  }

  when(Connecting) {
    case Event(SessionEstablished(token), _) =>
      goto(Connected) using SessionData(token)
    case Event(ConnectionFailed, _) =>
      goto(Disconnected) using NoData
  }

  when(Connected) {
    case Event(Disconnect, _) =>
      goto(Disconnected) using NoData
  }
}

class ConnectionFSMSpec extends TestKit(ActorSystem("FSMSpec"))
    with ImplicitSender with AnyWordSpecLike with Matchers with BeforeAndAfterAll {

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

  "ConnectionFSM" should {
    "start in Disconnected state" in {
      val fsm = TestFSMRef(new ConnectionFSM)
      fsm.stateName should be(Disconnected)
      fsm.stateData should be(NoData)
    }

    "transition to Connecting on Connect message" in {
      val fsm = TestFSMRef(new ConnectionFSM)
      fsm ! Connect("localhost:9000")
      fsm.stateName should be(Connecting)
    }

    "transition to Connected when session is established" in {
      val fsm = TestFSMRef(new ConnectionFSM)
      fsm ! Connect("localhost:9000")
      fsm ! SessionEstablished("tok_abc123")
      fsm.stateName should be(Connected)
      fsm.stateData should be(SessionData("tok_abc123"))
    }

    "return to Disconnected on connection failure" in {
      val fsm = TestFSMRef(new ConnectionFSM)
      fsm ! Connect("localhost:9000")
      fsm ! ConnectionFailed
      fsm.stateName should be(Disconnected)
    }
  }
}

Testing Actor Supervision

Test that a supervisor restarts or stops a child actor on failure:

"A Supervisor" should {
  "restart a child actor on ArithmeticException" in {
    val supervisor = system.actorOf(Props[Supervisor]())
    val probe = TestProbe()

    // Watch for lifecycle events
    supervisor ! GetChild
    val child = expectMsgType[ActorRef]
    probe.watch(child)

    child ! CauseArithmeticException

    // The child should NOT terminate (Restart, not Stop)
    probe.expectNoMessage(500.millis)
    child ! Ping
    expectMsg(Pong)
  }

  "stop a child actor on IllegalStateException" in {
    val supervisor = system.actorOf(Props[Supervisor]())
    supervisor ! GetChild
    val child = expectMsgType[ActorRef]

    watch(child)
    child ! CauseIllegalState
    expectTerminated(child)
  }
}

ActorSystem Lifecycle in Test Suites

Creating an ActorSystem per test class is correct but has overhead. For suites with many tests, share one system per class (already the pattern above). For maximum isolation between test classes, use one system per class and always call TestKit.shutdownActorSystem:

override def afterAll(): Unit = {
  TestKit.shutdownActorSystem(system, duration = 10.seconds, verifySystemShutdown = true)
}

verifySystemShutdown = true causes the test to fail if the system doesn't shut down cleanly, which surfaces dead-letter storms or actor lifecycle bugs.

Avoiding Flakiness

The most common source of flaky actor tests:

  1. Using Thread.sleep instead of expectNoMessageexpectNoMessage(200.millis) is deterministic; Thread.sleep is not.
  2. Sharing an ActorSystem across test files — actors from test A can send messages during test B. One system per class.
  3. Hardcoded port numbers — use 0 for dynamic port assignment in tests.
  4. Not awaiting termination — assert expectTerminated after system.stop(actor) before creating a new actor with the same name.
// Clean actor teardown
val actor = system.actorOf(Props[MyActor](), "my-actor")
watch(actor)
system.stop(actor)
expectTerminated(actor)
// Now safe to create another actor named "my-actor"

Summary

  • Extend TestKit with ImplicitSender for the cleanest test syntax.
  • Use TestProbe for observing messages to and from actors without coupling tests to implementation.
  • Configure timeouts in application.conf instead of hardcoding them in assertions.
  • Use within blocks to assert timing constraints on multi-step interactions.
  • Use TestActorRef for synchronous white-box unit tests.
  • Use TestFSMRef to inspect FSM state directly.
  • Always shut down the ActorSystem in afterAll with verifySystemShutdown = true.

Akka's actor model is hard to test without the right tools. TestKit gives you all of them — the key is using them consistently and avoiding the temptation to reach for Thread.sleep.

Read more

Start now free