Swift: Unit Test a DataTaskPublisher With URLProtocol

How to implement the unit test for a DataTaskPublisher using URLProtocol
Andrea Scuderi - 9/4/19

In my last article, I went through the implementation of a data publisher with the Combine API and URLSession, and how to use it as an API client.

This time, we’ll create a Swift package for our API client and we’ll implement the unit test for it.

Last time, we proved the API client works by doing some integration testing against a Docker instance.

This time, by implementing the unit tests, we complete our code so we can maintain the library in the future and prove we didn’t miss relevant use cases.

A full unit test of our API client will require us to prove that the client works correctly, not only when the server is available and provides the correct response and data, but also when the response is not valid or the data is corrupted, or there is a network failure.

Note: you need Xcode 11 to complete this tutorial.

Swift Package Preparation

“The Swift Package Manager is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies.” — Swift Package Manager documentation

With Swift Package Manager, we will create a custom library and the unit tests for it.

Create a new package


From the command line, create a directory, in our case CombineAPIDemo:

mkdir CombineAPIDemo  
cd CombineAPIDemo

Initialize your new package:

swift package init

The package init will create the project scaffolding for a library:

Creating library package: CombineAPIDemoCreating Package.swift  
Creating README.md  
Creating .gitignore  
Creating Sources/  
Creating Sources/CombineAPIDemo/CombineAPIDemo.swift  
Creating Tests/  
Creating Tests/LinuxMain.swift  
Creating Tests/CombineAPIDemoTests/  
Creating Tests/CombineAPIDemoTests/CombineAPIDemoTests.swift  
Creating Tests/CombineAPIDemoTests/XCTestManifests.swift

Now, we generate the Xcode project:

swift package generate-xcodeproj

And we’ll open it:

open CombineAPIDemo.xcodeproj

Great! Your first package is ready! Now, you need to add the code.


Add the code to the package

Overwrite CombineAPIDemo.swift with the following code:

import Combine
import Foundation

enum APIError: Error {
    case invalidBody
    case invalidEndpoint
    case invalidURL
    case emptyData
    case invalidJSON
    case invalidResponse
    case statusCode(Int)
}

struct User: Codable {
    let name: String
    let email: String
    let password: String
    let verifyPassword: String
}

struct CreateUserResponse: Codable {
    let id: Int
    let email: String
    let name: String
}

struct Todo: Codable {
    let id: Int?
    let title: String
}

struct Token: Codable {
    let string: String
}

@available(OSX 10.15, iOS 13.0, *)
protocol APIDataTaskPublisher {
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
}

@available(OSX 10.15, iOS 13.0, *)
class APISessionDataPublisher: APIDataTaskPublisher {
    
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
        return session.dataTaskPublisher(for: request)
    }
    
    var session: URLSession
    
    init(session: URLSession = URLSession.shared) {
        self.session = session
    }
}

@available(OSX 10.15, iOS 13.0, *)
struct APIDemo {
    
    static let baseURL = "http://localhost:8080"
    
    static let defaultHeaders = [
        "Content-Type": "application/json",
        "cache-control": "no-cache",
    ]
    
    static var timeoutInterval: TimeInterval = 10.0
    
    static var publisher: APIDataTaskPublisher = APISessionDataPublisher()
    
    internal static func buildHeaders(key: String, value: String) -> [String: String] {
        var headers = defaultHeaders
        headers[key] = value
        return headers
    }
    
    internal static func basicAuthorization(email: String, password: String) -> String {
        let loginString = String(format: "%@:%@", email, password)
        let loginData: Data = loginString.data(using: .utf8)!
        return loginData.base64EncodedString()
    }
    
    private static func buildPostUserRequest(user: User) -> URLRequest {
        let encoder = JSONEncoder()
        guard let postData = try? encoder.encode(user) else {
            fatalError("APIError.invalidEndpoint")
        }
        guard let url = URL(string: baseURL + "/users" ) else {
            fatalError("APIError.invalidEndpoint")
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = defaultHeaders
        request.httpBody = postData as Data
        
        return request
    }
    
    internal static func postUserDTP(user: User) throws -> URLSession.DataTaskPublisher {
        let request = buildPostUserRequest(user: user)
        return publisher.dataTaskPublisher(for: request)
    }
        
    private static func buildLoginRequest(email: String, password: String) -> URLRequest {
        
        let base64LoginString = basicAuthorization(email: email, password: password)
        
        let headers = buildHeaders(key: "Authorization", value: "Basic \(base64LoginString)")
        
        guard let url = URL(string: baseURL + "/login" ) else {
            fatalError("APIError.invalidEndpoint")
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        return request
    }
    
    internal static func postLoginDTP(email: String,password: String) -> URLSession.DataTaskPublisher {
        let request = buildLoginRequest(email: email, password: password)
        return publisher.dataTaskPublisher(for: request)
    }
    
    private static func buildPostTodoRequest(authToken: String, body: Todo) -> URLRequest {
        
        let headers = buildHeaders(key: "Authorization", value: "Bearer \(authToken)")
        let encoder = JSONEncoder()
        guard let postData = try? encoder.encode(body) else {
            fatalError("APIError.invalidBody")
        }
        guard let url = URL(string: baseURL + "/todos" ) else {
            fatalError("APIError.invalidEndpoint")
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        request.httpBody = postData
        return request
    }

    internal static func postTodoDTP(authToken: String, body: Todo) -> URLSession.DataTaskPublisher {
        let request = buildPostTodoRequest(authToken: authToken, body: body)
        return publisher.dataTaskPublisher(for: request)
    }
    
    private static func buildGetTodoRequest(authToken: String) -> URLRequest {
        
        let headers = buildHeaders(key: "Authorization", value: "Bearer \(authToken)")
        guard let url = URL(string: baseURL + "/todos" ) else {
            fatalError("APIError.invalidEndpoint")
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpMethod = "GET"
        request.allHTTPHeaderFields = headers
        return request
    }

    internal static func getTodoDTP(authToken: String) -> URLSession.DataTaskPublisher {
        let request = buildGetTodoRequest(authToken: authToken)
        return publisher.dataTaskPublisher(for: request)
    }

    private static func buildDeleteTodoRequest(authToken: String, id: Int) -> URLRequest {
        
        let headers = buildHeaders(key: "Authorization", value: "Bearer \(authToken)")
        guard let url = URL(string: baseURL + "/todos/\(id)" ) else {
            fatalError("APIError.invalidEndpoint")
        }
        var request = URLRequest(url: url, timeoutInterval: timeoutInterval)
        request.httpMethod = "DELETE"
        request.allHTTPHeaderFields = headers
        return request
    }

    internal static func deleteTodoDTP(authToken: String, id: Int) -> URLSession.DataTaskPublisher {
        let request = buildDeleteTodoRequest(authToken: authToken, id: id)
        return publisher.dataTaskPublisher(for: request)
    }
    
    
    internal static func validate(_ data: Data, _ response: URLResponse) throws -> Data {
        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }
        guard (200..<300).contains(httpResponse.statusCode) else {
            throw APIError.statusCode(httpResponse.statusCode)
        }
        return data
    }

    static func create(user: User) -> AnyPublisher<CreateUserResponse, Error>? {
        return try? postUserDTP(user: user)
            .tryMap{ try validate($0.data, $0.response) }
            .decode(type: CreateUserResponse.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
            
    static func login(email: String, password: String) -> AnyPublisher<Token, Error> {
        return postLoginDTP(email: email, password: password)
                .tryMap{ try validate($0.data, $0.response) }
                .decode(type: Token.self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
    }

    static func postTodo(authToken: String, todo: Todo) -> AnyPublisher<Todo, Error> {
        return  postTodoDTP(authToken: authToken, body: todo)
                .tryMap{ try validate($0.data, $0.response) }
                .decode(type: Todo.self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
    }

    static func getTodo(authToken: String) -> AnyPublisher<[Todo], Error> {
        return getTodoDTP(authToken: authToken)
                .tryMap{ try validate($0.data, $0.response) }
                .decode(type: [Todo].self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
    }

    static func deleteTodo(authToken: String, id: Int) -> AnyPublisher<Todo, Error> {
        return deleteTodoDTP(authToken: authToken, id: id)
                .tryMap{ try validate($0.data, $0.response) }
                .decode(type: Todo.self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
    }
}

I refactored some classes that we discussed in the previous article, so it will be possible to build a DataTaskPublisher with a custom session, which will be injected during the unit test.

Our APIDemo.publisher is now based on the APIDataTaskPublisher protocol, implemented by the class APISessionDataTaskPublisher. This will allow us to change the URLSession inside the publisher by using a mock for our responses.

Inside the APIDemo class we have three different types of static functions:

  • Public: The functions we want to show to the package consumer.
  • Internal: The functions we need to unit test.
  • Private: The functions used as utilities, used by the internal functions. We cannot test it directly as they are not accessible by the unit test class, but they will be tested as part of the test of the internal functions.

Great! Now we have the code to unit test. Try to build it, to check that it builds correctly.


Let’s Dive Into the Unit Tests

Internal functions returning a DataTaskPublisher

The unit test of the internal functions returning a DataTaskPublisher will test that the request created is correct.

func testPostUserDTP() {  
   let future = try? APIDemo.postUserDTP(user: self.mocks.user)  
   let request =  future?.request  
   XCTAssertEqual(request?.url?.absoluteString, APIDemo.baseURL + "/users")
   ...
   ...
}

Mocking the URLSession

Add a new Swift file under the Tests\CombineAPIDemoTests folder and name it URLProtocolMock with the following content:

import Foundation

//References:
//  --: https://www.hackingwithswift.com/articles/153/how-to-test-ios-networking-code-the-easy-way
//  --: https://nshipster.com/nsurlprotocol/

@objc class URLProtocolMock: URLProtocol {
    // this dictionary maps URLs to test data
    static var testURLs = [URL?: Data]()
    static var response: URLResponse?
    static var error: Error?
    
    // say we want to handle all types of request
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
    
    override class func canInit(with task: URLSessionTask) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    override func startLoading() {
        // if we have a valid URL…
        if let url = request.url {
            // …and if we have test data for that URL…
            if let data = URLProtocolMock.testURLs[url] {
                // …load it immediately.
                self.client?.urlProtocol(self, didLoad: data)
            }
        }
        
        // …and we return our response if defined…
        if let response = URLProtocolMock.response {
            self.client?.urlProtocol(self,
                                     didReceive: response,
                                     cacheStoragePolicy: .notAllowed)
        }
        
        // …and we return our error if defined…
        if let error = URLProtocolMock.error {
            self.client?.urlProtocol(self, didFailWithError: error)
        }
        // mark that we've finished
        self.client?.urlProtocolDidFinishLoading(self)
    }

    // this method is required but doesn't need to do anything
    override func stopLoading() {

    }
}

The URLProtocolMock is the key to create a mocked URLSession with our custom responses and data. The class allows to pass our Data, URLResponse, and Error to simulate the behaviour of a network request.

The mocked URLSession will be created in this way:

let config = URLSessionConfiguration.ephemeralconfig.protocolClasses = [URLProtocolMock.self]
let session = URLSession(configuration: config)

So, we can create a custom APISessionDataPublisher and assign it to the APIDemo.publisher to mock the network call.

let customPublisher = APISessionDataPublisher(session: session)  
APIDemo.publisher = customPublisher

We can setup our response:

let usersURL = URL(string: APIDemo.baseURL + "/users")

URLProtocolMock.testURLs = [usersURL: Data(Fixtures.createUserResponse.utf8)]  
URLProtocolMock.response = mocks.validResponse

And create our mocked API client publisher.

let customPublisher = APISessionDataPublisher(session: session)

Implement a reusable test function

For each test call, we need to test the client for the following conditions:

  • A valid response.
  • An invalid response due to an invalid URLResponse.
  • An invalid response due to an invalid Data.
  • An invalid response due to a network failure.

The following code will show the implementation of the two functions required to test a generic publisher, when it is Valid or Invalid.

func evalValidResponseTest<T:Publisher>(publisher: T?) -> (expectations:[XCTestExpectation], cancellable: AnyCancellable?) {
        XCTAssertNotNil(publisher)
        
        let expectationFinished = expectation(description: "finished")
        let expectationReceive = expectation(description: "receiveValue")
        let expectationFailure = expectation(description: "failure")
        expectationFailure.isInverted = true
        
        let cancellable = publisher?.sink (receiveCompletion: { (completion) in
            switch completion {
            case .failure(let error):
                print("--TEST ERROR--")
                print(error.localizedDescription)
                print("------")
                expectationFailure.fulfill()
            case .finished:
                expectationFinished.fulfill()
            }
        }, receiveValue: { response in
            XCTAssertNotNil(response)
            print(response)
            expectationReceive.fulfill()
        })
        return (expectations: [expectationFinished, expectationReceive, expectationFailure],
                cancellable: cancellable)
    }
    
    func evalInvalidResponseTest<T:Publisher>(publisher: T?) -> (expectations:[XCTestExpectation], cancellable: AnyCancellable?) {
        XCTAssertNotNil(publisher)
        
        let expectationFinished = expectation(description: "Invalid.finished")
        expectationFinished.isInverted = true
        let expectationReceive = expectation(description: "Invalid.receiveValue")
        expectationReceive.isInverted = true
        let expectationFailure = expectation(description: "Invalid.failure")
        
        let cancellable = publisher?.sink (receiveCompletion: { (completion) in
            switch completion {
            case .failure(let error):
                print("--TEST FULFILLED--")
                print(error.localizedDescription)
                print("------")
                expectationFailure.fulfill()
            case .finished:
                expectationFinished.fulfill()
            }
        }, receiveValue: { response in
            XCTAssertNotNil(response)
            print(response)
            expectationReceive.fulfill()
        })
         return (expectations: [expectationFinished, expectationReceive, expectationFailure],
                       cancellable: cancellable)
    }

The sink function will perform an asynchronous network call.

We need to define all the expectations that we will return so we can wait for them inside our test.

Here is an example of how to configure the test for a valid response:

//Setup URLSession Mock
let usersURL = URL(string: APIDemo.baseURL + "/users")
URLProtocolMock.testURLs = [usersURL:      Data(Fixtures.createUserResponse.utf8)]
URLProtocolMock.response = mocks.validResponse

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [URLProtocolMock.self]
let session = URLSession(configuration: config)
let customPublisher = APISessionDataPublisher(session: session)
APIDemo.publisher = customPublisher

// Create the Publisher
let publisher = APIDemo.create(user: self.mocks.user)

// Test the Publisher
let validTest = evalValidResponseTest(publisher: publisher)
wait(for: validTest.expectations, timeout:testTimeout)
validTest.cancellable?.cancel()

Unit testing a DataTaskPublisher

Here is the full example of the test for the create function:

func testCreate() {

        //Setup fixture
        let usersURL = URL(string: APIDemo.baseURL + "/users")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.createUserResponse.utf8)]
        
        //1) When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.create(user: self.mocks.user)

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2) When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.create(user: self.mocks.user)
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3) When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.create(user: self.mocks.user)
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4) Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.create(user: self.mocks.user)
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }

Wrapping Up

Overwrite CombineAPIDemoTests.swift with the following content:

import XCTest
import Foundation
import Combine

@testable import CombineAPIDemo

class Mocks {
    let user = User(name: "name",
                    email: "email",
                    password: "password",
                    verifyPassword: "password")
    let todo = Todo(id: 1, title: "test1")
    
    let authorization = "ZW1haWw6cGFzc3dvcmQ="

    let invalidResponse = URLResponse(url: URL(string: "http://localhost:8080")!,
                                      mimeType: nil,
                                      expectedContentLength: 0,
                                      textEncodingName: nil)
    
    let validResponse = HTTPURLResponse(url: URL(string: "http://localhost:8080")!,
                                        statusCode: 200,
                                        httpVersion: nil,
                                        headerFields: nil)
    
    let invalidResponse300 = HTTPURLResponse(url: URL(string: "http://localhost:8080")!,
                                           statusCode: 300,
                                           httpVersion: nil,
                                           headerFields: nil)
    let invalidResponse401 = HTTPURLResponse(url: URL(string: "http://localhost:8080")!,
                                             statusCode: 401,
                                             httpVersion: nil,
                                             headerFields: nil)
    
    let networkError = NSError(domain: "NSURLErrorDomain",
                               code: -1004,
                               userInfo: nil)
    
}

struct Fixtures {
    static let createUserResponse = """
            { "id": 1,
              "email": "email",
              "name": "name"}
    """
    
    static let tokenResponse = """
    {
        "string": "mytoken"
    }
    """
    
    static let todoResponse = """
    {
        "id": 1,
        "title": "test"
    }
    """
    
    static let todosResponse = """
    [{
        "id": 1,
        "title": "test"
    },
    {
        "id": 2,
        "title": "test 2"
    }]
    """
}

@available(OSX 10.15, iOS 13.0, *)
final class CombineAPIDemoTests: XCTestCase {
    
    let testTimeout: TimeInterval = 1
    
    var mocks: Mocks!
    var customPublisher: APISessionDataPublisher!
    
    override func setUp() {
        // URLProtocolMock.setup()
        self.mocks = Mocks()
        
        // now set up a configuration to use our mock
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [URLProtocolMock.self]
        
        // and create the URLSession from that
        let session = URLSession(configuration: config)
        customPublisher = APISessionDataPublisher(session: session)
    }
    
    override func tearDown() {
        self.mocks = nil
        
        //Restore the default publisher
        APIDemo.publisher = APISessionDataPublisher()
        
        URLProtocolMock.response = nil
        URLProtocolMock.error = nil
        URLProtocolMock.testURLs = [URL?: Data]()
    }
    
    func testBaseURL() {
        XCTAssertEqual(APIDemo.baseURL, "http://localhost:8080")
    }
    
    func testDefaultHeaders() {
        XCTAssertEqual(APIDemo.defaultHeaders["Content-Type"], "application/json")
        XCTAssertEqual(APIDemo.defaultHeaders["cache-control"], "no-cache")
        XCTAssertEqual(APIDemo.defaultHeaders.count, 2)
    }
    
    func testBuildHeaders() {
        let headers = APIDemo.buildHeaders(key: "key", value: "value")
        XCTAssertEqual(headers["Content-Type"], "application/json")
        XCTAssertEqual(headers["cache-control"], "no-cache")
        XCTAssertEqual(headers["key"], "value")
        XCTAssertEqual(headers.count, 3)
    }
    
    func testBasicAuthorization() {
        let authorization = APIDemo.basicAuthorization(email: "email", password: "password")
        XCTAssertEqual(authorization, "ZW1haWw6cGFzc3dvcmQ=")
    }
    
    func testPostUserDTP() {
        let future = try? APIDemo.postUserDTP(user: self.mocks.user)
        let request =  future?.request
        XCTAssertEqual(request?.url?.absoluteString, APIDemo.baseURL + "/users")
        XCTAssertEqual(request?.httpMethod, "POST")
        XCTAssertEqual(request?.allHTTPHeaderFields?.count, APIDemo.defaultHeaders.count)
        XCTAssertEqual(request?.timeoutInterval, APIDemo.timeoutInterval)
        XCTAssertNotNil(request?.httpBody)
        if let body = request?.httpBody {
            let decoder = JSONDecoder()
            let user = try? decoder.decode(User.self, from: body)
            XCTAssertNotNil(user)
        }
    }
    
    func testPostLoginDTP() {
        let future = APIDemo.postLoginDTP(email: "email", password: "password")
        let request =  future.request
        XCTAssertEqual(request.url?.absoluteString, APIDemo.baseURL + "/login")
        XCTAssertEqual(request.httpMethod, "POST")
        XCTAssertEqual(request.allHTTPHeaderFields?.count, APIDemo.defaultHeaders.count + 1)
        let authorization = "Basic \(APIDemo.basicAuthorization(email: "email", password: "password"))"
        XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], authorization)
        XCTAssertEqual(request.timeoutInterval, APIDemo.timeoutInterval)
        XCTAssertNil(request.httpBody)
    }
    
    func testPostTodoDTP() {
        let future = APIDemo.postTodoDTP(authToken: self.mocks.authorization,
                                      body: self.mocks.todo)
        let request =  future.request
        XCTAssertEqual(request.url?.absoluteString, APIDemo.baseURL + "/todos")
        XCTAssertEqual(request.httpMethod, "POST")
        XCTAssertEqual(request.allHTTPHeaderFields?.count, APIDemo.defaultHeaders.count + 1)
        let authorization = "Bearer \(self.mocks.authorization)"
        XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], authorization)
        XCTAssertEqual(request.timeoutInterval, APIDemo.timeoutInterval)
        XCTAssertNotNil(request.httpBody)
        if let body = request.httpBody {
            let decoder = JSONDecoder()
            let todo = try? decoder.decode(Todo.self, from: body)
            XCTAssertNotNil(todo)
        }
    }
    
    func testGetTodoDTP() {
        let future: URLSession.DataTaskPublisher = APIDemo.getTodoDTP(authToken: self.mocks.authorization)
        let request =  future.request
        XCTAssertEqual(request.url?.absoluteString, APIDemo.baseURL + "/todos")
        XCTAssertEqual(request.httpMethod, "GET")
        XCTAssertEqual(request.allHTTPHeaderFields?.count, APIDemo.defaultHeaders.count + 1)
        let authorization = "Bearer \(self.mocks.authorization)"
        XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], authorization)
        XCTAssertEqual(request.timeoutInterval, APIDemo.timeoutInterval)
        XCTAssertNil(request.httpBody)
    }
    
    func testDeleteTodoDTP() {
        let future: URLSession.DataTaskPublisher = APIDemo.deleteTodoDTP(authToken: self.mocks.authorization, id: 1)
        let request =  future.request
        XCTAssertEqual(request.url?.absoluteString, APIDemo.baseURL + "/todos/1")
        XCTAssertEqual(request.httpMethod, "DELETE")
        XCTAssertEqual(request.allHTTPHeaderFields?.count, APIDemo.defaultHeaders.count + 1)
        let authorization = "Bearer \(self.mocks.authorization)"
        XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], authorization)
        XCTAssertEqual(request.timeoutInterval, APIDemo.timeoutInterval)
        XCTAssertNil(request.httpBody)
    }
    
    func testValidate() {
        XCTAssertThrowsError(try APIDemo.validate(Data(), self.mocks.invalidResponse))
        XCTAssertThrowsError(try APIDemo.validate(Data(), self.mocks.invalidResponse300!))
        XCTAssertThrowsError(try APIDemo.validate(Data(), self.mocks.invalidResponse401!))
        
        let data = try? APIDemo.validate(Data(), self.mocks.validResponse!)
        XCTAssertNotNil(data)
    }
    
    func evalValidResponseTest<T:Publisher>(publisher: T?) -> (expectations:[XCTestExpectation], cancellable: AnyCancellable?) {
        XCTAssertNotNil(publisher)
        
        let expectationFinished = expectation(description: "finished")
        let expectationReceive = expectation(description: "receiveValue")
        let expectationFailure = expectation(description: "failure")
        expectationFailure.isInverted = true
        
        let cancellable = publisher?.sink (receiveCompletion: { (completion) in
            switch completion {
            case .failure(let error):
                print("--TEST ERROR--")
                print(error.localizedDescription)
                print("------")
                expectationFailure.fulfill()
            case .finished:
                expectationFinished.fulfill()
            }
        }, receiveValue: { response in
            XCTAssertNotNil(response)
            print(response)
            expectationReceive.fulfill()
        })
        return (expectations: [expectationFinished, expectationReceive, expectationFailure],
                cancellable: cancellable)
    }
    
    func evalInvalidResponseTest<T:Publisher>(publisher: T?) -> (expectations:[XCTestExpectation], cancellable: AnyCancellable?) {
        XCTAssertNotNil(publisher)
        
        let expectationFinished = expectation(description: "Invalid.finished")
        expectationFinished.isInverted = true
        let expectationReceive = expectation(description: "Invalid.receiveValue")
        expectationReceive.isInverted = true
        let expectationFailure = expectation(description: "Invalid.failure")
        
        let cancellable = publisher?.sink (receiveCompletion: { (completion) in
            switch completion {
            case .failure(let error):
                print("--TEST FULFILLED--")
                print(error.localizedDescription)
                print("------")
                expectationFailure.fulfill()
            case .finished:
                expectationFinished.fulfill()
            }
        }, receiveValue: { response in
            XCTAssertNotNil(response)
            print(response)
            expectationReceive.fulfill()
        })
         return (expectations: [expectationFinished, expectationReceive, expectationFailure],
                       cancellable: cancellable)
    }
    
    func testCreate() {

        //Setup fixture
        let usersURL = URL(string: APIDemo.baseURL + "/users")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.createUserResponse.utf8)]
        
        //1 When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.create(user: self.mocks.user)

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2 When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.create(user: self.mocks.user)
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3 When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.create(user: self.mocks.user)
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4) Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.create(user: self.mocks.user)
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }
    
    func testPostLogin() {
        
        //Setup fixture
        let usersURL = URL(string: APIDemo.baseURL + "/login")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.tokenResponse.utf8)]
        
        //1 When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.login(email: "email", password: "password")

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2 When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.login(email: "email", password: "password")
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3 When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.login(email: "email", password: "password")
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4 Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.login(email: "email", password: "password")
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }
    
    func testPostTodo() {
        
        //Setup fixture
        let todo = Todo(id: 1, title: "test")
        let usersURL = URL(string: APIDemo.baseURL + "/todos")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.todoResponse.utf8)]
        
        //1 When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.postTodo(authToken: "token", todo: todo)

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2 When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.postTodo(authToken: "token", todo: todo)
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3 When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.postTodo(authToken: "token", todo: todo)
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4 Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.postTodo(authToken: "token", todo: todo)
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }
    
    func testGetTodo() {
        
        //Setup fixture
        let usersURL = URL(string: APIDemo.baseURL + "/todos")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.todosResponse.utf8)]
        
        //1 When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.getTodo(authToken: "token")

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2 When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.getTodo(authToken: "token")
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3 When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.getTodo(authToken: "token")
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4 Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.getTodo(authToken: "token")
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }
    
    func testDeleteTodo() {
        
        //Setup fixture
        let usersURL = URL(string: APIDemo.baseURL + "/todos/1")
        URLProtocolMock.testURLs = [usersURL: Data(Fixtures.todoResponse.utf8)]
        
        //1 When is valid
        APIDemo.publisher = customPublisher
        URLProtocolMock.response = mocks.validResponse
        let publisher = APIDemo.deleteTodo(authToken: "token", id: 1)

        let validTest = evalValidResponseTest(publisher: publisher)
        wait(for: validTest.expectations, timeout: testTimeout)
        validTest.cancellable?.cancel()
        
        //2 When has invalid response
        URLProtocolMock.response = mocks.invalidResponse
        let publisher2 = APIDemo.deleteTodo(authToken: "token", id: 1)
        let invalidTest = evalInvalidResponseTest(publisher: publisher2)
        wait(for: invalidTest.expectations, timeout: testTimeout)
        invalidTest.cancellable?.cancel()
        
        //3 When has invalid data and valid response
        URLProtocolMock.testURLs[usersURL] = Data("{{}".utf8)
        URLProtocolMock.response = mocks.validResponse
        
        let publisher3 = APIDemo.deleteTodo(authToken: "token", id: 1)
        let invalidTest3 = evalInvalidResponseTest(publisher: publisher3)
        wait(for: invalidTest3.expectations, timeout: testTimeout)
        invalidTest3.cancellable?.cancel()
        
        //4 Network Failure
        URLProtocolMock.response = mocks.validResponse
        URLProtocolMock.error = mocks.networkError
        
        let publisher4 = APIDemo.deleteTodo(authToken: "token", id: 1)
        let invalidTest4 = evalInvalidResponseTest(publisher: publisher4)
        wait(for: invalidTest4.expectations, timeout: testTimeout)
        invalidTest4.cancellable?.cancel()
    }
    
    static var allTests = [
        ("testBaseURL", testBaseURL),
        ("testDefaultHeaders", testDefaultHeaders),
        ("testBasicAuthorization", testBasicAuthorization),
        ("testBuildHeaders", testBuildHeaders),
        ("testPostUserDTP", testPostUserDTP),
        ("testPostLoginDTP", testPostLoginDTP),
        ("testPostTodoDTP", testPostTodoDTP),
        ("testGetTodoDTP", testGetTodoDTP),
        ("testDeleteTodoDTP", testDeleteTodoDTP),
        ("testValidate", testValidate),
        ("testCreate", testCreate),
        ("testPostLogin", testPostLogin),
        ("testPostTodo", testPostTodo),
        ("testGetTodo", testGetTodo),
        ("testDeleteTodo", testDeleteTodo)
    ]
}

You have now the full test for your API client.

In Xcode, run the unit tests using the CTRL+U shortcut from the keyboard, or, from the menu, Product->Test.

XCode Unit Tests

That’s all! Thanks for reading the full article, I hope you liked it.

Here is the full project.

References


Upgrade Your Swift API Client With Combine

Implement a data publisher with the Combine API and the URLSession, and how to use it as an API client


apple/swift-package-manager


Simply put: a package is a git repository with semantically versioned tags, that contains Swift sources and a…


How to test iOS networking code the easy way


It's commonly agreed that unit tests should be FIRST: Fast, Isolated, Repeatable, Self-verifying, and Timely. Sadly…


NSURLProtocol

iOS is all about networking-whether it's reading or writing state to and from the server, offloading computation to a…

Tagged with: Swift Foundation Combine