Testing Async/Await Methods

XCTest fully supports async/await. Here's how to write clean, reliable tests for your async code.
Follow along with the code: iOS-Practice on GitHub
Async Test Functions
Mark your test function as async and you can await directly:
func test_getWeather_callsService() async throws {
// Act
_ = try await sut.getWeather(for: "London")
// Assert
XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 1)
XCTAssertEqual(mockService.requestedCities, ["London"])
}
No callbacks, no expectations, no completion handlers. Just clean, sequential code.
Mock Async Services
class MockWeatherService: WeatherService {
var weatherToReturn: Weather?
var errorToThrow: Error?
var fetchDelay: TimeInterval = 0
private(set) var fetchCurrentWeatherCallCount = 0
private(set) var requestedCities: [String] = []
func fetchCurrentWeather(for city: String) async throws -> Weather {
fetchCurrentWeatherCallCount += 1
requestedCities.append(city)
if fetchDelay > 0 {
try await Task.sleep(nanoseconds: UInt64(fetchDelay * 1_000_000_000))
}
if let error = errorToThrow { throw error }
return weatherToReturn ?? Weather(
city: city,
temperature: 72,
condition: "Sunny",
humidity: 50,
date: Date()
)
}
func reset() {
weatherToReturn = nil
errorToThrow = nil
fetchDelay = 0
fetchCurrentWeatherCallCount = 0
requestedCities = []
}
}
Testing Error Propagation
func test_getWeather_whenServiceThrows_propagatesError() async {
// Arrange
mockService.errorToThrow = WeatherError.cityNotFound
// Act & Assert
do {
_ = try await sut.getWeather(for: "FakeCity")
XCTFail("Expected error to be thrown")
} catch {
XCTAssertEqual(error as? WeatherError, .cityNotFound)
}
}
Testing with Delays
Sometimes you need to simulate slow responses:
func test_getWeather_withSlowService_completesEventually() async throws {
// Arrange
mockService.fetchDelay = 0.5 // 500ms delay
// Act - This should complete despite the delay
let weather = try await sut.getWeather(for: "SlowCity")
// Assert
XCTAssertEqual(weather.city, "SlowCity")
}
Test Setup with @MainActor
If your ViewModel is @MainActor, your tests need to be too:
@MainActor
class WeatherViewModelTests: XCTestCase {
var mockService: MockWeatherService!
var sut: WeatherViewModel!
override func setUp() async throws {
mockService = MockWeatherService()
sut = WeatherViewModel(service: mockService)
}
func test_loadWeather_updatesState() async {
mockService.weatherToReturn = Weather(city: "NYC", ...)
await sut.loadWeather(for: "NYC")
XCTAssertEqual(sut.currentWeather?.city, "NYC")
}
}
Key Points
| Pattern | Use Case |
|---|---|
async throws test | Most async tests |
| Mock with delay | Testing timeout behavior |
@MainActor test class | Testing MainActor-isolated code |
do/catch in test | Verifying specific errors |
Async testing in Swift is straightforward—just mark the test async and await your calls.