← Back to blog

Testing iOS Apps — Integration Tests with 3rd Party

A guide to mocking external dependencies and using protocol-oriented programming for robust iOS integration testing.

Testing integration between your code and external libraries presents unique challenges. Several categories of dependencies require special handling:

Hardware API Libraries:

  • CoreLocation (geolocation and movement)
  • AVFoundation (audio/visual access)
  • HealthKit (user health data)
  • URLSession (network communication)

External Libraries:

  • Firebase Analytics
  • Realm (data persistence)
  • Alamofire (HTTP networking)

Protocol-Oriented Programming Fundamentals

Protocols in Swift function similarly to interfaces in other languages — they define contracts that implementing types must follow. This approach enables:

  • Polymorphism: Different classes responding identically to the same function calls
  • Multiple Inheritance: Single classes implementing multiple protocols simultaneously

Creating Robust Mocks Through Wrapper Pattern

Rather than mocking external dependencies directly, the recommended approach uses a Wrapper — an abstraction layer around third-party functionality.

NetworkSession Protocol

protocol NetworkSession {
    func fetchData(from url: URL) async throws -> (Data, URLResponse)
}

MockNetworkSession Implementation

class MockNetworkSession: NetworkSession {
    var data: Data?
    var response: URLResponse?
    var error: Error?

    func fetchData(from url: URL) async throws -> (Data, URLResponse) {
        if let error = error {
            throw error
        }
        guard let data = data, let response = response else {
            throw URLError(.badServerResponse)
        }
        return (data, response)
    }
}

This mock supports three testing scenarios:

  • Simulating failures with custom errors
  • Returning correct data responses
  • Simulating different HTTP status codes

Testing Integration

func testFetchWeatherSuccess() async throws {
    let mockSession = MockNetworkSession()
    mockSession.data = """
      {
        "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
        "city": "San Francisco",
        "weatherCondition": "Sunny",
        "date": "2024-06-12T12:00:00Z"
      }
      """.data(using: .utf8)
    mockSession.response = HTTPURLResponse(
                              url: url,
                              statusCode: 200,
                              httpVersion: nil,
                              headerFields: nil
                            )
    let service = WeatherService(session: mockSession)

    let weather = try await service.fetchWeather(for: url)

    XCTAssertEqual(weather.city, "San Francisco")
    XCTAssertEqual(weather.weatherCondition, "Sunny")
}

func testFetchWeatherFail() async throws {
    let mockSession = MockNetworkSession()
    mockSession.error = URLError(.badURL)
    let service = WeatherService(session: mockSession)

    do {
        let weather = try await service.fetchWeather(for: url)
        XCTFail("This should fail")
    } catch (let e) {
        XCTAssertNotNil(e)
    }
}

Service Implementation

class WeatherService {
    private let session: NetworkSession

    init(session: NetworkSession) {
        self.session = session
    }

    func fetchWeather(for url: URL) async throws -> Weather {
        let (data, response) = try await session.fetchData(from: url)
        let weather = try JSONDecoder().decode(Weather.self, from: data)
        return weather
    }
}

URLSession Wrapper

class URLSessionWrapper: NetworkSession {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetchData(from url: URL) async throws -> (Data, URLResponse) {
        return try await session.data(for: URLRequest(url: url))
    }
}

Flexibility and Maintainability Benefits

Should your team need to replace URLSession with Alamofire, the wrapper pattern minimizes impact. You simply create a new wrapper implementation:

import Alamofire

class AlamofireSessionWrapper: NetworkSession {
    func fetchData(from url: URL) async throws -> (Data, URLResponse) {
        return try await withCheckedThrowingContinuation { continuation in
            AF.request(url).validate().responseData { response in
                switch response.result {
                case .success(let data):
                    if let httpResponse = response.response {
                        continuation.resume(returning: (data, httpResponse))
                    } else {
                        continuation.resume(throwing: URLError(.badServerResponse))
                    }
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

This approach maintains the same interface, ensuring your business logic remains unchanged and adaptable to library modifications or replacements.