Testing Swift Concurrency: async/await, Actors, and MainActor
Swift's structured concurrency model — async/await, actors, Task, and MainActor — requires specific testing patterns. The good news: Xcode 14+ makes async testing natural, and the actor model gives you clear isolation boundaries that map well to test setup.
Async Test Methods
Any XCTestCase method can be async (and optionally throws):
import XCTest
final class UserServiceTests: XCTestCase {
func testFetchUser() async throws {
let service = UserService()
let user = try await service.fetchUser(id: 1)
XCTAssertEqual(user.id, 1)
XCTAssertFalse(user.name.isEmpty)
}
func testFetchMissingUser() async {
let service = UserService()
let user = await service.findUser(id: 999)
XCTAssertNil(user)
}
}XCTest manages the async context — no Task { } wrapper, no RunLoop.main.run() hacks needed.
Testing Async Throwing Functions
Use async throws and the standard do/catch or XCTAssertThrowsError equivalent:
func testNetworkError() async {
let service = UserService(client: FailingHTTPClient())
do {
_ = try await service.fetchUser(id: 1)
XCTFail("Expected error to be thrown")
} catch NetworkError.timeout {
// expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}Or with XCTAssertThrowsError for async:
// Swift Testing framework (Xcode 16+)
#expect(throws: NetworkError.timeout) {
try await service.fetchUser(id: 1)
}Testing Actors
Actors serialize access to their state. Tests call actor methods the same way production code does — with await:
actor Counter {
private(set) var value = 0
func increment() { value += 1 }
func reset() { value = 0 }
}
final class CounterTests: XCTestCase {
func testIncrement() async {
let counter = Counter()
await counter.increment()
await counter.increment()
let value = await counter.value
XCTAssertEqual(value, 2)
}
func testReset() async {
let counter = Counter()
await counter.increment()
await counter.reset()
XCTAssertEqual(await counter.value, 0)
}
}Actor methods don't run concurrently — each test method awaits the actor serially.
Testing @MainActor ViewModels
ViewModels are commonly annotated with @MainActor to ensure UI updates run on the main thread. Test them with @MainActor on the test class or method:
@MainActor
final class LoginViewModelTests: XCTestCase {
var viewModel: LoginViewModel!
var mockAuthService: MockAuthService!
override func setUp() {
mockAuthService = MockAuthService()
viewModel = LoginViewModel(authService: mockAuthService)
}
func testLogin_setsLoadingState() async {
mockAuthService.delay = 0.1 // simulate network latency
let loginTask = Task { await viewModel.login() }
// Before login completes
XCTAssertTrue(viewModel.isLoading)
await loginTask.value // wait for completion
XCTAssertFalse(viewModel.isLoading)
}
func testLogin_success_clearsError() async {
viewModel.email = "user@example.com"
viewModel.password = "password"
await viewModel.login()
XCTAssertNil(viewModel.errorMessage)
}
func testLogin_failure_setsError() async {
mockAuthService.stubbedError = AuthError.invalidCredentials
await viewModel.login()
XCTAssertNotNil(viewModel.errorMessage)
XCTAssertTrue(viewModel.errorMessage!.contains("invalid"))
}
}Marking the entire test class @MainActor means every test method runs on the main actor, matching the ViewModel's isolation.
Testing Published Properties
@Published properties emit on the main thread. Test them with Combine or by checking after await:
import Combine
final class StateTests: XCTestCase {
func testStatePublisher() async {
let viewModel = SearchViewModel()
var states: [SearchState] = []
var cancellables = Set<AnyCancellable>()
viewModel.$state
.sink { states.append($0) }
.store(in: &cancellables)
await viewModel.search(query: "swift")
XCTAssertTrue(states.contains(.loading))
XCTAssertTrue(states.last.map { if case .results = $0 { true } else { false } } ?? false)
}
}For simpler cases, just check the final state after the async call resolves:
func testSearchReturnsResults() async {
let viewModel = SearchViewModel(repository: MockRepository())
await viewModel.search(query: "swift")
if case .results(let items) = viewModel.state {
XCTAssertFalse(items.isEmpty)
} else {
XCTFail("Expected results state, got \(viewModel.state)")
}
}Testing Task Cancellation
Verify that your code handles task cancellation gracefully:
func testCancellationStopsProcessing() async {
let processor = LongRunningProcessor()
var completed = false
let task = Task {
await processor.process(items: largeDataSet)
completed = true
}
try? await Task.sleep(nanoseconds: 10_000_000) // let it start
task.cancel()
await task.value // wait for cancellation to propagate
XCTAssertFalse(completed)
}For code that checks Task.isCancelled:
actor Processor {
func process(items: [Item]) async {
for item in items {
guard !Task.isCancelled else { return }
await processItem(item)
}
}
}Structured Concurrency in Tests
async let and TaskGroup work in async test methods:
func testConcurrentFetches() async throws {
let service = UserService()
async let user1 = service.fetchUser(id: 1)
async let user2 = service.fetchUser(id: 2)
async let user3 = service.fetchUser(id: 3)
let results = try await [user1, user2, user3]
XCTAssertEqual(results.count, 3)
}
func testTaskGroupCollectsAll() async throws {
let ids = [1, 2, 3, 4, 5]
let users = try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask { try await UserService().fetchUser(id: id) }
}
return try await group.reduce(into: []) { $0.append($1) }
}
XCTAssertEqual(users.count, 5)
}CheckedContinuation for Callback-Based APIs
Wrap completion handler APIs for testing:
func testCallbackAPI() async throws {
let result = try await withCheckedThrowingContinuation { continuation in
legacyClient.fetch(url: url) { data, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
}
}
}
XCTAssertFalse(result.isEmpty)
}Clock Injection for Time Control
Inject a clock to test time-sensitive code without real delays:
// Swift 5.7+ Clock protocol
protocol AppClock {
func sleep(nanoseconds: UInt64) async throws
}
struct SystemClock: AppClock {
func sleep(nanoseconds: UInt64) async throws {
try await Task.sleep(nanoseconds: nanoseconds)
}
}
struct InstantClock: AppClock {
func sleep(nanoseconds: UInt64) async throws {
// no-op — instant
}
}
final class RetryService {
private let clock: AppClock
init(clock: AppClock = SystemClock()) { self.clock = clock }
func fetchWithRetry(maxAttempts: Int) async throws -> Data {
for attempt in 1...maxAttempts {
do {
return try await fetch()
} catch {
guard attempt < maxAttempts else { throw error }
try await clock.sleep(nanoseconds: 1_000_000_000)
}
}
fatalError()
}
}
func testRetryDoesNotWait() async throws {
let service = RetryService(client: FailOnFirstTwoClient(), clock: InstantClock())
let result = try await service.fetchWithRetry(maxAttempts: 3)
XCTAssertFalse(result.isEmpty)
// No real 2-second wait
}Production Monitoring
Async tests validate concurrent code correctness in isolation. For production confidence — verifying that your Swift backend or API behaves correctly under real concurrent load — HelpMeTest monitors live endpoints 24/7 without requiring source code or a simulator.
Summary
- Async test methods just need
async(and optionallythrows) — no special wrapper - Actor methods require
awaitin tests, same as production code - Mark test class
@MainActorto match@MainActorViewModel isolation - Check state after awaiting the async call for
@Publishedproperties - Test cancellation by calling
task.cancel()and awaitingtask.value - Inject a no-op clock to test retry/delay logic without real waits
async letandTaskGroupwork naturally in async test methods