When implementing a typical mobile app, one of the first things I think about is the networking layer.

In Part 1 I described what is the best way to start. Now, focusing on actually testing it.


Should we test?

The units that form our network layer are:

  • APIClient
  • APIService
  • APISpec
  • Middleware

For such a small layer, do we really need tests? The answer is YES. 

From my experience, mobile apps projects ignore testing the networking layer for multiple reasons: e.g. assuming the API will always behave properly, assuming the connection will always be stable and strong enough or there is not a proper and a simple way to mock responses and requests.

These days though, Swift language is helpful enough: strong typing, error handling mechanism (e.g. Result type), powerful protocols, Decodable types, over-powered enums. 


What do we want to achieve?

From a holistic point of view, the importance of testing cannot be stated enough. The north star should be reliability, performance, trust that everything works (usually), or scalability - things that mobile developers and stakeholders care about.

From a technical and pragmatic point of view - what we want is a set of actionable strategies for achieving the goals of testing. For example, mocking the external APIs, decoupling the networking logic from business logic, fast test execution, testing various use-cases.

To balance both points, I prefer to test the actual implementation as much as possible instead of using protocols for the sake of mocking. This enables me to test comprehensively the units, components or modules that rely on the networking layer without actually hitting real external APIs. 

This may lead to more integration tests than unit tests (e.g. if you consider a unit to be a class/struct). And that is ok. From my experience, these tend to give me more confidence that a specific flow/subflow or use-case works as expected. In the end, we build for users so it makes sense to test from a user point of view.

Stubbing API calls

So, in this note, one of the goals of my strategy is to make stubbing the API client as easy as possible. Stubbing enables me to replace the actual network calls with predefined responses without reliance on an external server.

What I propose in our case, is the following approach:

let singleTodoResponse = """
          {
            "userId": 1,
            "id": 156,
            "title": "delectus aut autem",
            "completed": false
          }
        """

let apiClient = APIClient(baseURL: URL(string: "api.example.com")!)
            .stub(
                json: singleTodoResponse,
                code: 200,
                endpoint: "/todos/156"
            )


In this declaration, we stub a successful response (status code 200) for a specific API call api.example.com/todos/156 by defining a JSON response. This allows us to go as far as possible to the edge of the layer to test as much as possible - including the actual JSON to objects serialization. 

How to implement the stub(json: code: endpoint:) method?

Building the mocked URLProtocol

First of all, let's define a custom URL protocol that intercepts the network requests. 

class MockedURLProtocol: URLProtocol {
    static let endpoint = "<mocked-endpoint>"
    private static var stubs: [String: [ExpectedResponse]] = [:]
    
    private static func stub(response: ExpectedResponse, for endpoint: String) {
        if let responses = stubs[endpoint] {
            stubs[endpoint] = responses + [response]
        } else {
            stubs[endpoint] = [response]
        }
    }

    /// Clears all stubs.
    static func reset() {
        stubs = [:]
    }
}

MockedURLProtocol inherits from URLProtocol - an abstract class which deals with protocol specific URL loading.

It stores stub responses in a static dictionary, where each key is an endpoint. This allwos for multiple stubbed responses per endpoint.

The reset() method clears all stubs, ensuring that each test starts with a clean slate.

Building the ExpectedResponse struct

First, we define a struct that will handle responses from network calls, encapsulating the HTTP status code and the content, which can be either data or an error.

struct ExpectedResponse {
    let statusCode: Int
    let content: Result<Data, Error>
    
    init(data: Data, statusCode: Int) {
        self.statusCode = statusCode
        self.content = .success(data)
    }

    init(error: Error, statusCode: Int) {
        self.statusCode = statusCode
        self.content = .failure(error)
    }
}

Next, add the initializers to handle both successful and failed responses. They take data or an error and a status code, creating either a successful or a failed Result.

Continuing with the MockedURLProtocol class, we can add various helper methods to handle different types of responses: data, errors, JSON strings, or file contents:

/// Stub methods
static func stub(data: Data, code: Int, endpoint: String = endpoint) {
    stub(response: ExpectedResponse(data: data, statusCode: code),
         for: endpoint)
}

static func stub(error: Error, code: Int, endpoint: String = endpoint) {
    stub(response: ExpectedResponse(error: error, statusCode: code),
         for: endpoint)
}

static func stub(json: String, code: Int, endpoint: String = endpoint) {
    stub(response: ExpectedResponse(data: json.data(using: .utf8)!, statusCode: code),
         for: endpoint)
}

static func stub(contentsOfFile url: URL, code: Int, endpoint: String = endpoint) {
    let content = try! String(contentsOf: url, encoding: .utf8)
    stub(json: content, code: code, endpoint: endpoint)
}

To actually handle responses, I added two methods to retrieve them based on the endpoint set in the incoming request:

static func response(for request: URLRequest) -> ExpectedResponse? {
    if let url = request.url?.absoluteString {
        for endpoint in stubs {
            if url.hasSuffix(endpoint.key) {
                return consume(endpoint.key)
            }
        }
    }
    return consume(endpoint)
}

static func consume(_ endpoint: String) -> ExpectedResponse? {
    let queue = stubs[endpoint]
    let response = queue?.first
    stubs[endpoint] = Array(queue?.dropFirst() ?? [])
    return response
}

This will remove the retrieved response from the expected responses array.

Now, there is still a problem - this class didn't implement the URLProtocol methods:

override static func canInit(with request: URLRequest) -> Bool {
    return true
}

override static func canonicalRequest(for request: URLRequest) -> URLRequest {
    return request
}

override func startLoading() {
    guard let stub = MockedURLProtocol.response(for: request) else {
        fatalError("No response stubbed for request: \(request)")
    }
    
    let header = request.allHTTPHeaderFields
    
    let response = HTTPURLResponse(
        url: request.url!,
        statusCode: stub.statusCode,
        httpVersion: nil,
        headerFields: header
    )!
    
    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    
    switch stub.content {
    case .failure(let error):
        client?.urlProtocol(self, didFailWithError: error)
        return
    case .success(let data):
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
        return
    }
}

override func stopLoading() { }

Extending the APIClient

Before adding the stubbing methods to APIClient, let's define a method to create a copy of the APIClient instance with a new URLSession. It will create a mutable copy of the current instance and will update its urlSession property with a new session. Then, it will return the modified instance.

struct APIClient {

    ///.... rest of the implementation


    func copy(session newSession: URLSession) -> APIClient {
        var apiClientCopy = self
        apiClientCopy.urlSession = newSession
        return apiClientCopy
    }
}

The APIClient stubbing methods:

extension APIClient {
    /// Stubs error to respond the next HTTP request with.
    /// - Parameter error: error of the response to the next request.
    /// - Parameter code: HTTP status code for the response. Defaults to 400.
    /// - Parameter endpoint: Path to the endpoint which the stubbed data should respond to. Defaults to a generic endpoint that responds to any requests.
    func stub(error: Error, code: Int, endpoint: String? = nil) -> APIClient {
        MockedURLProtocol.stub(error: error, code: code, endpoint: endpoint ?? MockedURLProtocol.endpoint)
        return copy(session: URLSession(configuration: URLSessionConfiguration.testing))
    }
    
    /// Stubs a string in JSON format to respond the next HTTP request with.
    /// - Parameter json: A string in JSON format with the contents of the response to the next request.
    /// - Parameter code: HTTP status code for the response. Defaults to 200.
    /// - Parameter endpoint: Path to the endpoint which the stubbed data should respond to. Defaults to a generic endpoint that responds to any requests.
    func stub(json: String, code: Int, endpoint: String? = nil) -> APIClient {
        MockedURLProtocol.stub(json: json, code: code, endpoint: endpoint ?? MockedURLProtocol.endpoint)
        return copy(session: URLSession(configuration: URLSessionConfiguration.testing))
    }
    
    /// Clears all stubs.
    func reset() -> APIClient {
        MockedURLProtocol.reset()
        return copy(session: URLSession(configuration: URLSessionConfiguration.testing))
    }
}

URLSessionConfiguration 

To finally hook up the APIClient with the mocked URLProtocol behaviour that will be used in tests, we need to specify a separate reusable URLSessionConfiguration object.

extension URLSessionConfiguration {
    static var testing: URLSessionConfiguration {
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockedURLProtocol.self] as [AnyClass]
        return configuration
    }
}

By including the MockedURLProtocol in the protocolClasses array, all HTTP requests made by this URLSession will be intercepted by MockedURLProtocol. 


Writing the tests

Finally, we have all the pieces that help us bulding a trustworthy test suite.

APIClient tests

To start writing tests, we don't need to specifically mock URLSession object. We can directly go and write:

final class APIClientTests: XCTestCase {
let baseURL = URL(string: "api.example.com")
    var sut: APIClient!
    
    override func tearDownWithError() throws {
        sut = sut.reset()
    }
    
    func returnCorrectTypeWhenGettingTodo() async throws {
        let todoId: Int = 156
        let singleTodoResponse = """
          {
            "userId": 1,
            "id": 156,
            "title": "delectus aut autem",
            "completed": false
          }
        """
        sut = APIClient(
            baseURL: baseURL!
        ).stub(json: singleTodoResponse, code: 200, endpoint: "/todos/\(todoId)")
        
        let apiSpec: TodosAPISpec = .getTodo(id: todoId)
        let todo = try await sut.sendRequest(apiSpec)
        
        XCTAssert(type(of: todo) == TodoDTO.self)
    }
    
    func failWith404WhenGettingTodo() async throws {
        let todoId: Int = 156
        let emptyTodoResponse = """
          
        """
        sut = APIClient(baseURL: baseURL!)
            .stub(json: emptyTodoResponse, code: 404, endpoint: "/todos/\(todoId)")
        
        let apiSpec: TodosAPISpec = .getTodo(id: todoId)
        do {
            let _ = try await sut.sendRequest(apiSpec)
            XCTFail("Expected an error to be thrown, but the call completed successfully.")
        } catch {
            
            XCTAssertTrue(error is APIClient.NetworkError, "The error is not of type APIClient.NetworkError.")
            
            if let apiError = error as? APIClient.NetworkError {
                switch apiError {
                case .requestFailed(let statusCode):
                    // This is what is expected for a 404 error
                    XCTAssertEqual(statusCode, 404)
                    break // Success case
                default:
                    XCTFail("Expected a requestFailed error, received \(apiError)")
                }
            }
        }
    }
    
    func createTodoWhenAPISpecIsCalled() async throws {
        let createtodoResponse =
        """
          {
            "userId": 1,
            "id": 156,
            "title": "Shop bananas",
            "completed": false
          }
        """
        
        let apiSpec: TodosAPISpec = .create(
            todo: TodoDTO(userId: 1, title: "Shop bananas")
        )
        sut = APIClient(baseURL: baseURL!)
            .stub(
                json: createtodoResponse,
                code: 201,
                endpoint: apiSpec.endpoint
            )
        
        let todo = try await sut.sendRequest(apiSpec)
        
        let expectedResponse = TodoDTO(
            id: 156, 
            userId: 1,
            title: "Shop bananas",
            completed: false
        )
        XCTAssertEqual(todo as! TodoDTO, expectedResponse)
    }
}

TodoAPIService tests

final class TodoAPIServiceTests: XCTestCase {
    let baseURL = URL(string: "api.example.com")
    
    func initializeAPIClientWhenCreated() throws {
        let apiClient = APIClient(
            baseURL: baseURL!
        )
        let sut = APIService(apiClient: apiClient)
        
        XCTAssertNotNil(sut.apiClient)
    }
    
    func returnSingleTodoWhenGetTodoIsCalled() async throws {
        let singleTodoResponse = """
          {
            "userId": 1,
            "id": 12,
            "title": "delectus aut autem",
            "completed": false
          }
        """
        
        let apiClient = APIClient(baseURL: baseURL!)
            .stub(json: singleTodoResponse, code: 200, endpoint: "/todos/12")
        
        
        let sut = TodosAPIService(apiClient: apiClient)
        let expectedTodo = TodoDTO(
            id: 12,
            userId: 1,
            title: "delectus aut autem",
            completed: false
        )
        
        let todoId: Int = 12
        let todo = try await sut.getTodo(withId: todoId)
        
        XCTAssertEqual(todo, expectedTodo)
    }
    
    func returnNilWhenSingleTodoResponseIsEmpty() async throws {
        let singleTodoResponseEmpty = """
          
        """
        
        let todoId: Int = 1456
        let apiClient = APIClient(baseURL: baseURL!)
            .stub(json: singleTodoResponseEmpty, code: 404, endpoint: "/todos/\(todoId)")
        let sut = TodosAPIService(apiClient: apiClient)
        
        let todo = try await sut.getTodo(withId: todoId)
        
        XCTAssertNil(todo)
    }
    
    func returnAllTodosWhenGetTodosIsCalled() async throws {
        let expectedTodos = [
            TodoDTO(
                id: 1,
                userId: 1,
                title: "delectus aut autem",
                completed: false
            ),
            TodoDTO(
                id: 2,
                userId: 1,
                title: "quis ut nam facilis et officia qui",
                completed: false
            ),
            TodoDTO(
                id: 3,
                userId: 1,
                title: "fugiat veniam minus",
                completed: false
            )
        ]
        let todosResponse = """
        [
          {
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": false
          },
          {
            "userId": 1,
            "id": 2,
            "title": "quis ut nam facilis et officia qui",
            "completed": false
          },
          {
            "userId": 1,
            "id": 3,
            "title": "fugiat veniam minus",
            "completed": false
          }
        ]
        """
        
        let apiClient = APIClient(baseURL: baseURL!)
            .stub(json: todosResponse, code: 200, endpoint: "/todos")
                
        let sut = TodosAPIService(apiClient: apiClient)
        let todos = try await sut.getTodos()
        
        XCTAssertEqual(todos, expectedTodos)
    }
    
    func returnEmptyArrayWhenTodosResponseIsEmpty() async throws {
        let expectedTodos: [TodoDTO] = []
        let todosResponse = """
        []
        """
        let apiClient = APIClient(baseURL: baseURL!)
            .stub(json: todosResponse, code: 200, endpoint: "/todos")
        
        
        let sut = TodosAPIService(apiClient: apiClient)
        let todos = try await sut.getTodos()
        
        XCTAssertEqual(todos, expectedTodos)
    }
}

Middleware tests

Let's add tests to check the middleware properly modifies the request, such as adding an authorization header, while ensuring the URL and HTTP method are unchanged.

final class AuthorizationMiddlewareTests: XCTestCase {
    var sut: AuthorizationMiddleware!
    var urlRequest: URLRequest!
    
    override func setUpWithError() throws {
        let url =  try XCTUnwrap(URL(string: "api.example.com/endpoint"))
        urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "GET"
    }
    
    func addEmptyAuthorizationHeaderWhenNoTokenIsProvided() async throws {
        sut = AuthorizationMiddleware()
        let interceptedRequest = try await sut.intercept(urlRequest)
        
        XCTAssertEqual(
            interceptedRequest.value(forHTTPHeaderField: "Authorization"),
            "Bearer "
        )
    }
    
    func addAuthorizationHeaderWhenTokenIsProvided() async throws {
        sut = AuthorizationMiddleware(token: "test-auth-token")
        let interceptedRequest = try await sut.intercept(urlRequest)
        
        XCTAssertEqual(
            interceptedRequest.value(forHTTPHeaderField: "Authorization"),
            "Bearer test-auth-token"
        )
    }
    
    func preserveURLWhenInterceptingRequest() async throws {
        sut = AuthorizationMiddleware(token: "test-auth-token")
        let interceptedRequest = try await sut.intercept(urlRequest)
        
        XCTAssertEqual(interceptedRequest.url, urlRequest.url)
    }
    
    func preserveHTTPMethodWhenInterceptingRequest() async throws {
        sut = AuthorizationMiddleware(token: "test-auth-token")
        let interceptedRequest = try await sut.intercept(urlRequest)
        
        XCTAssertEqual(interceptedRequest.httpMethod, urlRequest.httpMethod)
    }
}

And here are some examples of tests that check the APIClient - middlewares behaviour:

final class APIClientMiddlewaresTests: XCTestCase {
    let baseURL = URL(string: "api.example.com")
    var sut: APIClient!
    
    override func tearDownWithError() throws {
        sut = sut.reset()
    }
    
    func middlewaresBeEmptyWhenNoMiddlewaresProvided() async throws {
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: []
        )
        
        XCTAssert(sut.middlewares.isEmpty)
    }
    
    func middlewaresBeEmptyWhenMiddlewaresIsDefault() async throws {
        sut = APIClient(
            baseURL: baseURL!
        )
        
        XCTAssert(sut.middlewares.isEmpty)
    }
    
    func middlewaresBeAddedWhenMiddlewareIsProvided() async throws {
        let middlewareSpy = APIClient.MiddlewareSpy()
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [middlewareSpy]
        ).stub(json: "[]", code: 202, endpoint: "/todos")
        
        XCTAssert(!sut.middlewares.isEmpty)
    }
    
    func middlewareInterceptBeCalledOnceWhenRequestIsSent() async throws {
        let middlewareSpy = APIClient.MiddlewareSpy()
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [middlewareSpy]
        ).stub(json: "[]", code: 202, endpoint: "/todos")
        
        let apiSpec = TodosAPISpec.getTodos
        _ = try await sut.sendRequest(apiSpec)
        
        
        XCTAssertEqual(middlewareSpy.interceptMethodCalled, 1)
    }
    
    func loggerNotBeCalledWhenLoggingMiddlewareIsInitialized() async throws {
        let loggerSpy = LoggerSpy()
        let loggingMiddleware = LoggingMiddleware(logger: loggerSpy)
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [loggingMiddleware]
        )
        
        XCTAssertEqual(loggerSpy.logMethodCalled, 0)
        XCTAssertEqual(loggerSpy.logMethodParametersMessage, nil)
    }
    
    func logBeCalledOnceWhenRequestIsSentWithLoggingMiddleware() async throws {
        let loggerSpy = LoggerSpy()
        let loggingMiddleware = LoggingMiddleware(logger: loggerSpy)
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [loggingMiddleware]
        ).stub(json: "[]", code: 202, endpoint: "/todos")
        
        let apiSpec = TodosAPISpec.getTodos
        _ = try await sut.sendRequest(apiSpec)
        
        XCTAssertEqual(loggerSpy.logMethodCalled, 1)
        XCTAssertNotEqual(loggerSpy.logMethodCalled, 2)
    }
    
    func logRequestDetailsWhenRequestIsSentWithLoggingMiddleware() async throws {
        let loggerSpy = LoggerSpy()
        let loggingMiddleware = LoggingMiddleware(logger: loggerSpy)
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [loggingMiddleware]
        ).stub(json: "[]", code: 202, endpoint: "/todos")
        
        let apiSpec = TodosAPISpec.getTodos
        _ = try await sut.sendRequest(apiSpec)
        
        let expectedLogMessage =
        """
        GET api.example.com/todos
        Headers: [\"Content-Type\": \"application/json\"]
        """
        XCTAssertEqual(loggerSpy.logMethodParametersMessage, expectedLogMessage)
    }
    
    func middlewaresBeTwoWhenLoggingAndAuthorizationMiddlewareAreAdded() async throws {
        let loggerSpy = LoggerSpy()
        let loggingMiddleware = LoggingMiddleware(logger: loggerSpy)
        let authorizationMiddleware = AuthorizationMiddleware(token: "auth-token")
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [authorizationMiddleware, loggingMiddleware]
        )
        
        XCTAssertEqual(sut.middlewares.count, 2)
    }
    
    func authorizationHeaderBeMissingWhenLoggingMiddlewarePrecedesAuthorizationMiddleware() async throws {
        let loggerSpy = LoggerSpy()
        let loggingMiddleware = LoggingMiddleware(logger: loggerSpy)
        let authorizationMiddleware = AuthorizationMiddleware(token: "auth-token")
        sut = APIClient(
            baseURL: baseURL!,
            middlewares: [loggingMiddleware, authorizationMiddleware]
        ).stub(json: "[]", code: 202, endpoint: "/todos")
        
        let apiSpec = TodosAPISpec.getTodos
        _ = try await sut.sendRequest(apiSpec)
        
        // Authorization hearer not added
        let expectedLogMessage =
        """
        GET api.example.com/todos
        Headers: [\"Content-Type\": \"application/json\"]
        """
        XCTAssertEqual(loggerSpy.logMethodParametersMessage, expectedLogMessage)
    }
}

There are 2 helper spies that are used above:

class LoggerSpy: Logging {
    var logMethodCalled: UInt = 0
    var logMethodParametersMessage: String?
    
    func log(message: String) {
        logMethodCalled += 1
        logMethodParametersMessage = message
    }
}

extension APIClient {
    class MiddlewareSpy: Middleware {
        var interceptMethodCalled = 0
        
        func intercept(_ request: URLRequest) async throws -> (URLRequest) {
            interceptMethodCalled += 1
            return request
        }
    }
}


Thank you for reading! If you enjoyed this post and want to explore topics like this, don’t forget to subscribe to the newsletter. You’ll get the latest blog posts delivered directly to your inbox. Follow me on LinkedIn and Twitter/X. Your journey doesn’t have to end here. Subscribe, follow, and let’s take continue the conversation. This is the way!