§ 05 · 2026 · 05 · 05 · Testing
Testing Swift actors: a practical guide
Swift actors are easy to test, in the same way protocols are. The trick is to stop trying to mock the actor and start mocking what it talks to.
The first instinct most developers have when they hit a final actor they need to test is to look for a way to subclass it, swap a method, or override init. None of that works. Actors can't be subclassed. There's no final actor equivalent of the OCMock-era tricks.
That's fine. The actor is the thing under test; you don't need to mock it. What you need to mock are its collaborators — the presenter it talks to, the router it asks to route, the parent listener it sends intent up to, the service it calls. All of those are protocols. Replace the protocols with stub implementations that record calls. Done.
The recipe
For any actor under test, write three kinds of stubs:
- A presenter stub that records calls.
- A service stub that returns canned results.
- A listener stub (for the parent) that records what the actor sent upward.
For a router, you can usually mock it the same way — it's also a protocol. The wrinkle is that the router protocol often inherits from Routing with members like children: [Routing], which means more boilerplate. Worth extracting into a base.
Mocking a service
Services are the easiest. A final class that conforms to the protocol, with mutable state for what to return:
final class MockAuthService: AuthService, @unchecked Sendable {
var loginResult: Result<User, Error> = .success(.init(name: "Test", barbecueFoods: []))
var loginCalls = 0
var logoutCalls = 0
func login() async throws -> User {
loginCalls += 1
return try loginResult.get()
}
func logout() async throws { logoutCalls += 1 }
}
The Result-based stub lets one mock test the happy path and the failure path without rewriting. @unchecked Sendable because the counter is mutable; in a test the only thread touching it is the test method's, so the unchecked annotation is honest about the trade-off.
Driving the actor
The interactor's methods are async. The test method is async too. There's nothing else to it:
@MainActor
final class LaunchInteractorTests: XCTestCase {
func testDidTapLogin_callsServiceAndAttachesLoggedIn() async {
let auth = MockAuthService()
let user = User(name: "Smokey Joe", barbecueFoods: ["Brisket"])
auth.loginResult = .success(user)
let router = MockLaunchRouting()
let sut = LaunchInteractor(authService: auth)
await sut.set(router: router)
await sut.loggedOutDidTapLogin()
XCTAssertEqual(auth.loginCalls, 1)
XCTAssertEqual(router.attachLoggedInCalls.count, 1)
XCTAssertEqual(router.attachLoggedInCalls.first?.user, user)
}
}
Three things to notice. The test class is @MainActor — XCTest lifecycle is main-actor anyway, so it's the simplest place to keep it. The actor's methods take await because they're async. And assertions are about observable state: what the mock service recorded, what the mock router was asked to do. Not about the internals of the actor.
Lifecycle is just another method
If your actor has a didBecomeActive(), call it directly. There's no magic. The framework's activate() method exists too, and exercises the full lifecycle, but calling the hook method directly is often more legible:
func testDidBecomeActive_attachesLoggedOut() async {
let auth = MockAuthService()
let router = MockLaunchRouting()
let sut = LaunchInteractor(authService: auth)
await sut.set(router: router)
await sut.didBecomeActive()
XCTAssertEqual(router.attachLoggedOutCalls, 1)
}
Anti-patterns to skip
Don't make the actor @MainActor "for testability." It makes the test slightly simpler (no await on actor properties) and puts your business logic on the main thread in production. Bad trade.
Don't assert on order of awaits. Two awaits on the same actor are sequential by definition; the language guarantees it. Asserting "the service was called before the router was attached" is testing Swift, not your code. Assert on the end state.
Don't extract a "testable wrapper" around the actor. The protocols you already have are the seam. Adding another layer to "make testing easier" usually just moves the test to the wrong altitude.
Where to start
If you're moving onto Swift actors and don't have tests for your interactors yet, the "Testing a napkin" tutorial in the napkin docs walks the full pattern with example code. The example app's RibHouse also ships snapshot tests for its SwiftUI views — a parallel pattern for the view side.
Most teams that adopt this pattern report that writing tests for their actors is faster than writing tests for the view-model classes they replaced. The reason is mundane: actor methods take typed arguments and return typed values. You don't have to set up Combine subscribers to capture output, and you don't have to XCTestExpectation-wait for async work — the await is the wait.