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.

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…