Asynchronous Testing in Swift

Introduction

  • Issues with normal Unit testing
  • How to write asynchronous tests
  • Using XCTestExpectation
  • Mocking services
  • Testing async/await code

Issues with normal tests

func testTallestTowersShouldNotBeEmpty() { TowersDataManager.tallestTowers { result in 
switch result {
case .success(let towers): XCTAssert(towers.count > 0, "tallestTowers list is empty")
case .failure(let error):
XCTFail("Expected towers list, but failed \(error).")
}
}
}

Asynchronous testing: how to write asynchronous tests

Using XCTestExpectation

let expectation = XCTestExpectation(description: #function)
expectation.fulfill()
wait(for: [expectation], timeout: 3.0)
func testTallestTowersShouldNotBeEmptyy() { 
let expectation = XCTestExpectation(description: #function)
var tallestTowers: [Tower] = [] TowersDataManager.tallestTowers { result in
switch result {
case .success(let towers):
tallestTowers = towers
expectation.fulfill() case .failure(let error):
XCTFail("Expected towers list, but failed \(error).")
}
}
wait(for: [expectation], timeout: 3.0)
XCTAssert(tallestTowers.count > 0, "tallestTowers list is empty") }
  1. If it triggers the XCTFail method in the event of a thrown error.
  2. If the timeout expires before the completion handler is called, due to network or other issues.
  3. If the data is returned successfully, but the list is empty.
let expectation = expectation(description: #function) expectation.isInverted = true

Mocking services

protocol TowersNetworkSessionProtocol { 
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
}
class TowersNetworkSession: TowersNetworkSessionProtocol { 
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) { guard let url = url else {
completion(.failure(TowerNetworkError.invalidURL)) return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
}
if let data = data {
completion(.success(data))
} else {
completion(.failure(TowerNetworkError.missingData))
}
}
.resume()
}
}
class TowersMockNetworkSession: TowersNetworkSessionProtocol { 
var completion: Result<Data, Error>?
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) {
guard url != nil else { completion(.failure(TowerNetworkError.invalidURL))
return
}
self.completion.map(completion)
}
}
class TowersDataManager { 
private let session: TowersNetworkSessionProtocol
init(session: TowersNetworkSessionProtocol) {
self.session = session
}
func tallestTowers(completion: @escaping (Result<[Tower], Error>) -> ()) {
let url = URL(string: "https://tower.free.beeceptor.com/tallest")
session.execute(url: url) { result in
switch result {
case .success(let data):
let result = Result(catching: {
try JSONDecoder().decode([Tower].self, from: data)
})
completion(result)
case .failure(let error):
completion(.failure(error))
}
}
}
}
func testTallestTowersData() throws { 
// 1
var result: Result<[Tower], Error>?
// 2
let tallestTowers: [Tower] = Tower.tallestTowers
let response = try JSONEncoder().encode(tallestTowers)
// 3
let session = TowersMockNetworkSession() session.completion = .success(response)
// 4
let dataManager = TowersDataManager(session: session)
// 5
dataManager.tallestTowers { result = $0
}
// 6
XCTAssertEqual(try result?.get(), tallestTowers)
}
  1. Declare a result type that we’ll fill with the result from the completion handler.
  2. Use hardcoded data and encode it into JSON format.
  3. Create an object of the mock class and set the completion variable to register success with the desired response.
  4. Instantiate a data manager class passing the mock session as the argument.
  5. Call the tallestTowers method. Note that this does not hit the server; it just passes the completion handler.
  6. Assert to check if the obtained result and the hard-coded values are the same. If the JSON was parsed correctly, our test should pass.
func testTallestTowersForMissingData() { 
var result: Result<[Tower], Error>?
let session = TowersMockNetworkSession()
session.completion = .failure(TowerNetworkError.missingData)
let dataManager = TowersDataManager(session: session) dataManager.tallestTowers {
result = $0
}

XCTAssertThrowsError(try result?.get()) { error in
XCTAssertEqual(error as? TowerNetworkError, .missingData)
}
}

Expanding asynchronous testing: testing async/await code

protocol TowersNetworkSessionProtocol { 
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
func execute(url: URL?) async throws -> Data
}
class TowersNetworkSession: TowersNetworkSessionProtocol {  
func execute(url: URL?) async throws -> Data {
guard let url = url else {
throw TowerNetworkError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
class TowersMockNetworkSession: TowersNetworkSessionProtocol {
func execute(url: URL?) async throws -> Data {
guard url != nil else {
throw TowerNetworkError.invalidURL
}
guard let data = try completion?.get() else {
throw TowerNetworkError.missingData
}
return data
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) 
func testTallestTowersShouldNotBeEmptyAsync() async throws {
let session = TowersNetworkSession()
let dataManager = TowersDataManager(session: session)
do {
let tallestTowers = try await dataManager.tallestTowers()
XCTAssertTrue(tallestTowers.count > 0, "tallestTowers array is empty")
} catch {
XCTFail("Expected towers data, but failed \(error).")
}
}
func testTallestTowersDataAsync() async throws { 
let tallestTowers = Tower.tallestTowers
let response = try JSONEncoder().encode(tallestTowers)
let session = TowersMockNetworkSession()
session.completion = .success(response)
let dataManager = TowersDataManager(session: session)

let result = try await dataManager.tallestTowers()
XCTAssertEqual(result, tallestTowers)
}
func testTallestTowersForMissingDataAsync() async { 
let session = TowersMockNetworkSession()
session.completion = .failure(TowerNetworkError.missingData)
let dataManager = TowersDataManager(session: session) do {
_ = try await dataManager.tallestTowers()
XCTFail("Expected to throw missing data error, but succeeded.")
} catch {
XCTAssertEqual(error as? TowerNetworkError, .missingData)
}
}

Conclusion

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com