Step by step we are increasing the code coverage of the APIClient class. In the previous episode, you learned how to use the APIs the APIClient class exposes to unit test its private methods. Remember that the goal isn't to unit test the private methods of the APIClient class. The goal is to increase the code coverage of the APIClient class. We have a few more unit tests to write.
Deleting Progress for a Video
To delete the progress for a video, we send a DELETE request to the Cocoacasts API. Remember that the body of the response is empty and the status code is 204. Let's write a unit test for that scenario.
We first need to add the ability to stub the Cocoacasts API in such a way that it returns an empty response. This isn't too difficult if you watched the previous episodes. Open APIClientTests.swift and navigate to the stubAPI(endpoint:statusCode:) method. In the extension for APIClientTests, we define an enum with name Response. A Response object defines the response of a stubbed request.
The Response enum defines two cases, data and file. The data case defines an associated value with name data of type Data. The file case defines an associated value with name name of type String. Even though the associated values don't need a label per se, it is a good practice to use labels to avoid confusion as much as possible.
fileprivate extension APIClientTests {
// MARK: - Types
enum Response {
// MARK: - Cases
case data(data: Data)
case file(name: String)
}
// MARK: - Stub API
func stubAPI(endpoint: Endpoint, statusCode: StatusCode) -> HTTPStubsDescriptor {
...
}
}
Don't worry if you're still a bit confused. Let's use the Response enum in the stubAPI(endpoint:statusCode:) method. The stubAPI(endpoint:statusCode:) method defines a third parameter with name response of type Response.
fileprivate extension APIClientTests {
// MARK: - Types
enum Response {
// MARK: - Cases
case data(data: Data)
case file(name: String)
}
// MARK: - Stub API
func stubAPI(endpoint: Endpoint, statusCode: StatusCode, response: Response) -> HTTPStubsDescriptor {
...
}
}
In the if clause of the response handler of the stub(condition:response:) function, we switch on the value of the response parameter. In the data case, we use the status code and the Data object to create an HTTPStubsResponse instance. In the file case, we use the name of the file to create a response, using the fixture(filePath:status:headers:)` function.
func stubAPI(endpoint: Endpoint, statusCode: StatusCode, response: Response) -> HTTPStubsDescriptor {
stub { request in
request.url?.path == endpoint.path
} response: { request in
if statusCode.isSuccess {
switch response {
case .data(data: let data):
return HTTPStubsResponse(data: data, statusCode: Int32(statusCode), headers: nil)
case .file(name: let name):
if let filePath = OHPathForFile(name, type(of: self)) {
return fixture(filePath: filePath, status: Int32(statusCode), headers: [:])
} else {
fatalError("Unable to Find Stub in Bundle")
}
}
} else {
return HTTPStubsResponse(data: Data(), statusCode: Int32(statusCode), headers: nil)
}
}
}
These changes break the unit tests we wrote earlier, but they are easy to fix. Navigate to the runEpisodesTest(statusCode:) method. Define the episodes endpoint and assign it to a constant with name endpoint. We use the value of the endpoint constant as the first argument of the stubAPI(endpoint:statusCode:response:) method. The third argument of the stubAPI(endpoint:statusCode:response:) method is the response of the stubbed request. The response is the stub of the endpoint. Run the test suite to make sure we are still in the green.
private func runEpisodesTest(statusCode: StatusCode) {
let endpoint = Endpoint.episodes
stubAPI(
endpoint: endpoint,
statusCode: statusCode,
response: .file(name: endpoint.stub)
).store(in: &stubsDescriptors)
...
}
Let's write a unit test for the deleteProgressForVideo(id:) method. Name the unit test testDeleteProgressForVideo().
// MARK: - Tests for Delete Progress for Video
func testDeleteProgressForVideo() throws {
}
Before we implement the unit test, we add a case with name deleteVideoProgress to the Endpoint enum. The case defines an associated value with name id of type String.
fileprivate enum Endpoint {
// MARK: - Cases
case episodes
case deleteVideoProgress(id: String)
...
}
The compiler throws two errors because we need to make a few changes to the computed properties of the Endpoint enum. Updating the computed path property isn't difficult. We use the value of the associated value to build the path for the endpoint.
var path: String {
let path: String = {
switch self {
case .episodes:
return "episodes"
case .deleteVideoProgress(id: let id):
return "videos/\(id)/progress"
}
}()
return "/api/v1/\(path)"
}
The computed stub property is less trivial since we expect the response to be empty. We can solve this one of several ways. I like to throw a fatal error in the deleteVideoProgress case, explaining why the endpoint doesn't define a stub.
var stub: String {
switch self {
case .episodes:
return "episodes.json"
case .deleteVideoProgress:
fatalError("This endpoint doesn't define a stub. It returns a 204 response on success.")
}
}
Before we revisit the testDeleteProgressForVideo() method, we declare a private, constant property with name videoID and value "1". This keeps the object literals we use in the unit tests for the APIClient class to a minimum.
final class APIClientTests: XCTestCase {
// MARK: - Properties
...
// MARK: -
private let videoID = "1"
...
}
The setup of the testDeleteProgressForVideo() method is similar to that of the other unit tests. We create an Endpoint object to delete the progress for a video and pass it as an argument to the stubAPI(endpoint:statusCode:response:) method. The first argument is the Endpoint object, the second argument is 204, the status code of a successful request with no content, the third argument is an empty Data object wrapped in a Response object.
func testDeleteProgressForVideo() throws {
let endpoint = Endpoint.deleteVideoProgress(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 204,
response: .data(data: Data())
).store(in: &stubsDescriptors)
}
We define an expectation for the asynchronous unit test and invoke the method under test on the APIClient instance, deleteProgressForVideo(id:). We subscribe to the publisher the deleteProgressForVideo(id:) method returns by invoking the sink(receiveCompletion:receiveValue:) method, passing in a completion handler and a value handler. We fulfill the expectation in the completion handler. We can leave the value handler empty since the response of a successful request has no content. We invoke waitForExpectations(timeout:handler:) to wait for the expectation to be fulfilled.
func testDeleteProgressForVideo() throws {
let endpoint = Endpoint.deleteVideoProgress(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 204,
response: .data(data: Data())
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Delete Progress for Video")
apiClient.deleteProgressForVideo(id: videoID)
.sink { completion in
expectation.fulfill()
} receiveValue: { _ in }.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Run the unit test by clicking the diamond in the gutter on the left. The unit test passes without issues. Earlier in this series, we discussed the importance of designing your unit tests with care. The unit test we wrote has a flaw. Let me show you what I mean.
In the completion handler of the sink(receiveCompletion:receiveValue:) method, we switch on the Completion object. In the finished case, we fulfill the expectation because we expect the request to succeed. In the failure case, we add an empty tuple because that is a requirement. You could also add a break statement.
func testDeleteProgressForVideo() throws {
let endpoint = Endpoint.deleteVideoProgress(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 204,
response: .data(data: Data())
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Delete Progress for Video")
apiClient.deleteProgressForVideo(id: videoID)
.sink { completion in
switch completion {
case .finished:
expectation.fulfill()
case .failure:
()
}
} receiveValue: { _ in }.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Run the unit test one more time. This time the unit test fails due to a timeout. We run into a timeout because the request fails and the expectation isn't fulfilled. To understand why that is, we need to take a look at the implementation of the request(_:) method of the APIClient class.
In the request(_:) method, we use a do-catch statement to create the URL request. The request(accessToken:) method of the APIEndpoint enum throws an error if the endpoint requires authorization and no access token is passed to the request(accessToken:) method. Let's see if that hypothesis is true.
Revisit the testDeleteProgressForVideo() method and fulfill the expectation in the failure case. We add an empty tuple to the finished case. Run the unit test one more time. As you can see, the unit test passes.
func testDeleteProgressForVideo() throws {
let endpoint = Endpoint.deleteVideoProgress(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 204,
response: .data(data: Data())
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Delete Progress for Video")
apiClient.deleteProgressForVideo(id: videoID)
.sink { completion in
switch completion {
case .finished:
()
case .failure:
expectation.fulfill()
}
} receiveValue: { _ in }.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Even though this may feel like a setback, we can actually kill two birds with one stone. Let me explain what I have in mind. We write two unit tests for the deleteProgressForVideo(id:) method, one in which the user is authorized and one in which the user is unauthorized.
We first need to update the MockAccessTokenProvider struct. We change the computed accessToken property to a stored property, a constant. This change gives us the option to define whether the user is signed in or signed out.
import Foundation
@testable import Cocoacasts
struct MockAccessTokenProvider: AccessTokenProvider {
// MARK: - Properties
let accessToken: String?
}
Open APIClientTests.swift and remove the apiClient property. While it can be convenient to set up unit tests in the setUpWithError() method, it can reduce flexibility and we need that flexibility moving forward. We no longer create an APIClient instance in the setUpWithError() method.
override func setUpWithError() throws {
}
We create an APIClient instance in the runEpisodesTest(statusCode:) method. Because the /api/v1/episodes endpoint doesn't require the user to be signed in, we pass nil to the initializer of the MockAccessTokenProvider struct.
private func runEpisodesTest(statusCode: StatusCode) {
let apiClient = APIClient(
accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
)
let endpoint = Endpoint.episodes
...
}
We define the two methods for the unit tests for the deleteProgressForVideo(id:) method, testDeleteProgressForVideo_Authorized() and testDeleteProgressForVideo_Unauthorized(). We change the name of the testDeleteProgressForVideo() method to runDeleteProgressForVideoTest(isAuthorized:) and define it as not throwing. It defines one parameter, isAuthorized, of type Bool. We invoke the runDeleteProgressForVideoTest(isAuthorized:) method in testDeleteProgressForVideo_Authorized() and testDeleteProgressForVideo_Unauthorized(), passing in true and false respectively.
// MARK: - Tests for Delete Progress for Video
func testDeleteProgressForVideo_Authorized() throws {
runDeleteProgressForVideoTest(isAuthorized: true)
}
func testDeleteProgressForVideo_Unauthorized() throws {
runDeleteProgressForVideoTest(isAuthorized: false)
}
func runDeleteProgressForVideoTest(isAuthorized: Bool) {
...
}
We create a MockAccessTokenProvider object in the runDeleteProgressForVideoTest(isAuthorized:) method. We only pass an access token to the initializer if isAuthorized is equal to true. We use the access token provider to create an APIClient instance.
func runDeleteProgressForVideoTest(isAuthorized: Bool) {
let accessTokenProvider = MockAccessTokenProvider(
accessToken: isAuthorized ? "123456" : nil
)
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
...
}
The only other change we need to make is updating the completion handler of the sink(receiveCompletion:receiveValue:) method. We fulfill the expectation after the switch statement. In the finished case, we invoke the XCTFail(_:file:line:) function if isAuthorized is equal to false because we don't expect the request to succeed if the user isn't authorized. In the failure case, we invoke the XCTFail(_:file:line:) function if isAuthorized is equal to true because we expect the request to succeed if the user is authorized. In the else clause, we assert that the error, the associated value of the failure case, is equal to APIError.unauthorized.
func runDeleteProgressForVideoTest(isAuthorized: Bool) {
...
apiClient.deleteProgressForVideo(id: videoID)
.sink { completion in
switch completion {
case .finished:
if !isAuthorized {
XCTFail("Request Should Fail")
}
case .failure(let error):
if isAuthorized {
XCTFail("Request Should Succeed")
} else {
XCTAssertEqual(error, APIError.unauthorized)
}
}
expectation.fulfill()
} receiveValue: { _ in }.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Run the test suite to make sure the unit tests for the deleteProgressForVideo(id:) method pass. Navigate to the request(_:) method of the APIClient class. Notice that we slowly but surely increase the code coverage of the request(_:) method and, as a result, the code coverage of the APIClient class. Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 75%.
What's Next?
It always gives me a good feeling when I see that the unit tests I write increase the code coverage of the entity under test. Some developers may think that unit testing a networking layer is tedious or even impossible, but I hope that this series is showing you that that isn't true. If you take your time, you end up with a networking layer that is properly covered with unit tests and a test suite that is readable, maintainable, and easy to extend.