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:
- Using
Thread.sleepinstead ofexpectNoMessage—expectNoMessage(200.millis)is deterministic;Thread.sleepis not. - Sharing an
ActorSystemacross test files — actors from test A can send messages during test B. One system per class. - Hardcoded port numbers — use
0for dynamic port assignment in tests. - Not awaiting termination — assert
expectTerminatedaftersystem.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
TestKitwithImplicitSenderfor the cleanest test syntax. - Use
TestProbefor observing messages to and from actors without coupling tests to implementation. - Configure timeouts in
application.confinstead of hardcoding them in assertions. - Use
withinblocks to assert timing constraints on multi-step interactions. - Use
TestActorReffor synchronous white-box unit tests. - Use
TestFSMRefto inspect FSM state directly. - Always shut down the
ActorSysteminafterAllwithverifySystemShutdown = 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.