Actor Isolation in Unit Tests

Actors provide data isolation, but testing them requires understanding how actor isolation affects your test code.
Follow along with the code: iOS-Practice on GitHub
The Challenge
Actors protect state from concurrent access, but your tests need to interact with that state. XCTest runs on the main thread, and actor methods are isolated.
actor SearchManager {
private let searchService: SearchService
private var currentTask: Task<[String], Error>?
init(searchService: SearchService) {
self.searchService = searchService
}
func search(query: String) async throws -> [String] {
currentTask?.cancel()
let task = Task {
try await Task.sleep(nanoseconds: 300_000_000)
try Task.checkCancellation()
return try await searchService.search(query: query)
}
currentTask = task
return try await task.value
}
func cancelSearch() {
currentTask?.cancel()
currentTask = nil
}
}
Testing Actor Methods
Actor methods are async by default when called from outside:
class SearchManagerTests: XCTestCase {
var mockService: MockSearchService!
var sut: SearchManager!
override func setUp() {
mockService = MockSearchService()
sut = SearchManager(searchService: mockService)
}
func test_search_returnsResults() async throws {
// Arrange
mockService.resultsToReturn = ["Swift", "SwiftUI"]
// Act - await is required for actor methods
let results = try await sut.search(query: "swift")
// Assert
XCTAssertEqual(results, ["Swift", "SwiftUI"])
}
}
Testing Cancellation
func test_search_cancelsPreviousSearch() async throws {
// Arrange
mockService.resultsToReturn = ["result"]
// Act - Start multiple rapid searches
Task { _ = try? await sut.search(query: "first") }
Task { _ = try? await sut.search(query: "second") }
try await Task.sleep(nanoseconds: 50_000_000)
let results = try await sut.search(query: "final")
// Assert - Only final search completes
XCTAssertEqual(results, ["result"])
}
func test_cancelSearch_stopsPendingSearch() async {
// Arrange
mockService.searchDelay = 1.0 // Long delay
// Act
let searchTask = Task {
try await sut.search(query: "test")
}
try? await Task.sleep(nanoseconds: 100_000_000)
await sut.cancelSearch()
// Assert
do {
_ = try await searchTask.value
XCTFail("Expected cancellation")
} catch {
XCTAssertTrue(error is CancellationError)
}
}
Mock for Actor Tests
class MockSearchService: SearchService {
var resultsToReturn: [String] = []
var errorToThrow: Error?
var searchDelay: TimeInterval = 0
private(set) var searchCallCount = 0
func search(query: String) async throws -> [String] {
searchCallCount += 1
if searchDelay > 0 {
try await Task.sleep(nanoseconds: UInt64(searchDelay * 1_000_000_000))
}
if let error = errorToThrow { throw error }
return resultsToReturn
}
}
@MainActor Test Classes
When testing @MainActor-isolated types:
@MainActor
class ViewModelTests: XCTestCase {
var sut: SomeViewModel!
override func setUp() async throws {
sut = SomeViewModel()
}
func test_someMethod_updatesState() async {
await sut.loadData()
XCTAssertEqual(sut.items.count, 3)
}
}
Key Patterns
| Scenario | Approach |
|---|---|
| Calling actor methods | Use await in async test |
| Testing cancellation | Start task, sleep briefly, cancel, verify error |
| @MainActor types | Mark test class with @MainActor |
| Verifying isolation | Check that concurrent calls don't corrupt state |
Actor tests are just async tests with the added guarantee that the actor handles isolation for you.