The networking layer we are building is nearing completion. We added support for most endpoints of the Cocoacasts API and, later in this series, we add support for refreshing an access token using a refresh token. In the next few episodes, we focus on unit testing the networking layer.
The networking layer is architected in such a way that unit testing isn't going to be painful. A comprehensive test suite is an integral component of the networking layer for several reasons.
First, the test suite ensures we are confident the networking layer behaves as expected. Second, as we write unit tests, we have the opportunity to take another close look at the code we wrote. That is a benefit of unit testing that is often overlooked. I wrote an article about this several years ago, Write Better Code By Writing Unit Tests. Third, unit tests make it easier to test edge cases, that is, scenarios that are difficult or impossible to test manually.
Adding a Test Target
The first step we need to take is adding a target for the test suite. Select the project in the Project Navigator and click the + button at the bottom of the Targets section.

Select the iOS tab at the top and scroll to the Test section. Choose Unit Testing Bundle and click Next.

Name the target CocoacastsTests. Make sure Project is set to Cocoacasts and Target to be Tested is set to Cocoacasts.

Xcode adds the CocoacastsTests target and creates a group with the same name. Everything related to the unit tests lives in that group. The group currently contains one file, CocoacastsTests.swift. We delete this file for now.
Unit Testing the APIClient Class
We start by writing unit tests for the APIClient class. Add a group with name Cases to the CocoacastsTests group. We use the Cases group to organize the test cases. Add a Swift file to the Cases group by choosing the Unit Test Case Class template from the iOS > Source section. Name the class APIClientTests. Xcode may ask you to create an Objective-C bridging header. Choose Don't Create from the list of options.
We declare the class final and import the Cocoacasts module. We prefix the import statement with the testable attribute to ensure we have access to entities that are declared internal. Remove the unit tests of the template. We don't need them.
import XCTest
@testable import Cocoacasts
final class APIClientTests: XCTestCase {
// MARK: - Set Up & Tear Down
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
}
Run the test suite by choosing Test from the Product menu or by pressing Command + U. The test suite passes without issues since it doesn't contain any unit tests. At this point, we want to make sure everything is set up correctly. Running the test suite should not result in any errors or warnings.
To better understand which methods we need to unit test, we open the APIClient class and explore its interface. Open the Assistant Editor on the right by choosing Assistant from the Editor menu. We are interested in the interface of the APIClient class. Choose Generated Interface > APIClient.swift from the jump bar at the top.

We only focus on methods that are declared open, public, or internal. It isn't possible to unit test private and fileprivate methods and that isn't a problem. I explain this in more detail in How to Unit Test Private Methods in Swift.
There is no need to unit test the initializer. This would be useful if the initializer were failable, but that isn't the case. The methods we are interested in unit testing are the ones defined by the APIService protocol. We start simple with the episodes() method.
import Combine
import Foundation
final internal class APIClient : APIService {
internal init(accessTokenProvider: AccessTokenProvider)
internal func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError>
internal func episodes() -> AnyPublisher<[Episode], APIError>
internal func video(id: String) -> AnyPublisher<Video, APIError>
internal func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError>
internal func updateProgressForVideo(id: String, cursor: Int) -> AnyPublisher<VideoProgressResponse, APIError>
internal func deleteProgressForVideo(id: String) -> AnyPublisher<NoContent, APIError>
}
Revisit APIClientTests.swift and declare a method with name testEpisodes(). We declare the method as throwing.
import XCTest
@testable import Cocoacasts
final class APIClientTests: XCTestCase {
// MARK: - Set Up & Tear Down
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
// MARK: - Tests for Episodes
func testEpisodes() throws {
}
}
To unit test the episodes() method, we need an APIClient instance. Remember that the initializer accepts a single argument of type AccessTokenProvider. Thanks to the protocol-oriented approach we adopted earlier in this series, this isn't a problem. We don't need to pass it a KeychainService instance like we do elsewhere in the project. The initializer of the APIClient class expects the object to conform to the AccessTokenProvider protocol. We decide what the concrete type of that object is as long as it conforms to AccessTokenProvider.
Add a group to the CocoacastsTests group and name it Mocks. A mock or mock object is an object that mocks or mimics the behavior of another object. The type we are about to create isn't a mock in the strict sense, but I tend to name the types that play a similar role mocks or mock objects for clarity. Add a Swift file to the Mocks group and name it MockAccessTokenProvider.
Add an import statement for the Cocoacasts module and prefix it with the testable attribute. Declare a struct with name MockAccessTokenProvider and conform it to the AccessTokenProvider protocol. The AccessTokenProvider protocol defines a single requirement, a property with name accessToken of type String?. To meet that requirement, we define a computed property, accessToken, of type String? that returns nil. That's it.
import Foundation
@testable import Cocoacasts
struct MockAccessTokenProvider: AccessTokenProvider {
// MARK: - Properties
var accessToken: String? {
nil
}
}
With the MockAccessTokenProvider struct in place, we can revisit APIClientTests.swift and create an APIClient instance. Navigate to the testEpisodes() method and create an APIClient instance. We pass a MockAccessTokenProvider instance to the initializer.
// MARK: - Tests for Episodes
func testEpisodes() throws {
let apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())
}
With the APIClient instance created, we can invoke the method under test, episodes(). Before we do, we add an import statement for the Combine framework at the top.
import XCTest
import Combine
@testable import Cocoacasts
final class APIClientTests: XCTestCase {
...
}
We define a private, variable property, subscriptions, for storing the subscriptions we create in the unit tests. The property is of type Set<AnyCancellable> and its initial value is an empty set. This should look familiar if you worked with the Combine framework.
import XCTest
import Combine
@testable import Cocoacasts
final class APIClientTests: XCTestCase {
// MARK: - Properties
private var subscriptions: Set<AnyCancellable> = []
...
}
Revisit the testEpisodes() method. In the unit test, we invoke the episodes() method on the APIClient instance. We subscribe to the publisher the episodes() method returns by invoking the sink(receiveCompletion:receiveValue:) method. The subscription the method returns is stored in the subscriptions property. It is more correct to say that we store a reference to the AnyCancellable instance the method returns in the subscriptions property.
// MARK: - Tests for Episodes
func testEpisodes() throws {
let apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())
apiClient.episodes()
.sink { completion in
} receiveValue: { episodes in
}.store(in: &subscriptions)
}
In the completion handler, we switch on the Completion object. We expect the request to succeed so we invoke the XCTFail(_:file:line:) function in the failure case.
// MARK: - Tests for Episodes
func testEpisodes() throws {
let apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())
apiClient.episodes()
.sink { completion in
switch completion {
case .finished:
()
case .failure:
XCTFail("Request Should Succeed")
}
} receiveValue: { episodes in
}.store(in: &subscriptions)
}
In the value handler, we invoke the XCTAssertEqual(_:_:_:file:line:) function to assert that the number of episodes the API returned is equal to 25.
// MARK: - Tests for Episodes
func testEpisodes() throws {
let apiClient = APIClient(accessTokenProvider: MockAccessTokenProvider())
apiClient.episodes()
.sink { completion in
switch completion {
case .finished:
()
case .failure:
XCTFail("Request Should Succeed")
}
} receiveValue: { episodes in
XCTAssertEqual(episodes.count, 25)
}.store(in: &subscriptions)
}
The unit test is ready to be executed. Click the diamond in the gutter on the left to execute the unit test. If it turns green, the unit test passed. If it turns red, the unit test failed. The unit test should pass without issues.
Deceptive Unit Tests
This example shows that a poorly designed unit test can be deceptive and give you a false sense of confidence. The unit test we implemented doesn't test anything. To understand what I mean, add a breakpoint to the completion and value handlers. Execute the unit test one more time. Notice that the breakpoints aren't hit.
A unit test is executed synchronously, but the request the API client makes to the Cocoacasts API is asynchronous. If you are unfamiliar with synchronous and asynchronous execution, I recommend taking a look at What Is Asynchronous Programming. You need to understand these concepts to understand what is wrong with the unit test we wrote.
We shouldn't use a synchronous unit test to test the episodes() method. What we need is an asynchronous unit test. The unit test needs to wait for the request to complete, successfully or unsuccessfully. That is only possible using an asynchronous unit test.
Stubbing the API
There is one other issue we need to address. The API client sends a request to the Cocoacasts API. That is what we expect from the API client. The problem is that the Cocoacasts API becomes a dependency of the unit test. If the Cocoacasts API is having issues or the device running the test suite doesn't have a network connection, the unit test inevitably fails. That isn't what we want. We want the test suite to be fast, reliable, and always have the same outcome. For that we need to decouple the test suite from the Cocoacasts API. That is something we cover next.
What's Next?
Even though the unit test we implemented isn't working as expected, we laid a foundation we can build upon. In the next episode, you learn how to implement an asynchronous unit test and how to stub the Cocoacasts API.