Unit tests are very useful for testing edge cases. We explored that in the previous episode. Because bugs sometimes hide in edge cases, unit tests can help you track down hard to find bugs. This is only true if the unit tests you write are sound and cover your code.
Catching Bugs
Open APIClient.swift and run the test suite. Navigate to the request(_:) method and enable code coverage in the source editor. There is one more unit test we need to write. In the closure we pass to the mapError operator, we throw an APIError.unreachable error if the error that is passed to the mapError operator is equal to URLError.notConnectedToInternet. Let's write a unit test for that scenario.
.mapError { error -> APIError in
switch error {
case let apiError as APIError:
return apiError
case URLError.notConnectedToInternet:
return APIError.unreachable
default:
return APIError.failedRequest
}
}
Open APIClientTests.swift. We need to write a unit test for the scenario in which the device doesn't have a network connection. To reliably unit test that scenario, we need to simulate a network failure. We do that by stubbing the Cocoacasts API.
While we could extend the stubAPI(endpoint:statusCode:response:) method, that method is aimed at simulating responses coming from the Cocoacasts API. If the device doesn't have a network connection, the request the client sends doesn't make it to the Cocoacasts API. For that reason, I prefer to define a separate method.
Define a method with name simulateFailure(endpoint:error:). The method accepts an Endpoint object as its first argument and an Error object as its second argument, and it returns an HTTPStubsDescriptor instance.
func simulateFailure(endpoint: Endpoint, error: Error) -> HTTPStubsDescriptor {
}
In the body of the simulateFailure(endpoint:error:) method, we invoke the stub(condition:response:) function. The condition closure is identical to that of the stubAPI(endpoint:statusCode:response:) method. In the response closure, we use the Error object to create an HTTPStubsResponse instance.
func simulateFailure(endpoint: Endpoint, error: Error) -> HTTPStubsDescriptor {
stub { request in
request.url?.path == endpoint.path
} response: { _ in
HTTPStubsResponse(error: error)
}
}
Let's write a few unit tests for the progressForVideo(id:) method of the APIClient class. You know the drill by now. We extend the Endpoint enum by adding a case with name progressForVideo. The progressForVideo case defines an associated value id of type String. The associated value is the identifier of the video.
fileprivate enum Endpoint {
// MARK: - Cases
case episodes
case video(id: String)
case progressForVideo(id: String)
case deleteVideoProgress(id: String)
// MARK: - Properties
...
}
We need to update the computed path and stub properties. Updating the computed path property is trivial since the Endpoint enum already supports deleting the progress for a video.
var path: String {
let path: String = {
switch self {
case .episodes:
return "episodes"
case .video(id: let id):
return "videos/\(id)"
case .progressForVideo(id: let id),
.deleteVideoProgress(id: let id):
return "videos/\(id)/progress"
}
}()
return "/api/v1/\(path)"
}
The next step is adding a stub for the endpoint. Add a file with name progress-for-video.json to the Stubs group. Make sure you add progress-for-video.json to the CocoacastsTests target, not the Cocoacasts target. The stub contains a cursor and a video ID.
With the stub added to the Stubs group, we can update the computed stub property. We add the progressForVideo case to the switch statement and return the name of the file we added to the Stubs group.
var stub: String {
switch self {
case .episodes:
return "episodes.json"
case .video:
return "video.json"
case .progressForVideo:
return "progress-for-video.json"
case .deleteVideoProgress:
fatalError("This endpoint doesn't define a stub. It returns a 204 response on success.")
}
}
Let's write the unit tests for the progressForVideo(id:) method of the APIClient class. I usually start with the happy path so I know everything is set up correctly. We make use of another private helper method to improve readability and avoid code duplication. Define a private method with name runProgressForVideoTest().
private func runProgressForVideoTest() {
}
Because the endpoint requires authorization, we create a mock access token provider that returns a valid access token. We pass the mock access token provider to the initializer of the APIClient class to create an APIClient instance.
private func runProgressForVideoTest() {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
}
We define the endpoint of the Cocoacasts API we need to stub and store it in a constant with name endpoint.
private func runProgressForVideoTest() {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.progressForVideo(id: videoID)
}
We stub the Cocoacasts API by invoking the stubAPI(endpoint:statusCode:response:) method. The first argument is the Endpoint object. The second argument is that status code of the response, 200. The third argument is the stub for the endpoint. We store the stubs descriptor in the array of stubs descriptors.
private func runProgressForVideoTest() {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.progressForVideo(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: .file(name: endpoint.stub)
).store(in: &stubsDescriptors)
}
We define an expectation for the asynchronous unit test and invoke the method under test on the APIClient instance, progressForVideo(id:). We subscribe to the publisher the progressForVideo(id:) method returns by invoking the sink(receiveCompletion:receiveValue:) method, passing in a completion handler and a value handler. We invoke waitForExpectations(timeout:handler:) to wait for the expectation to be fulfilled. This should feel familiar by now.
private func runProgressForVideoTest() {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.progressForVideo(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: .file(name: endpoint.stub)
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Progress for Video")
apiClient.progressForVideo(id: videoID)
.sink { completion in
} receiveValue: { videoProgressResponse in
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
In the completion handler of the sink(receiveCompletion:receiveValue:) method, we switch on the Completion object. In the finished case, we fulfill the expectation. In the failure case, we invoke the XCTFail(_:file:line:) function. In the value handler, we assert that the cursor of the VideoProgressResponse object is equal to 124.
private func runProgressForVideoTest() {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.progressForVideo(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: .file(name: endpoint.stub)
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Progress for Video")
apiClient.progressForVideo(id: videoID)
.sink { completion in
switch completion {
case .finished:
expectation.fulfill()
case .failure(let error):
XCTFail("Request Should Succeed")
}
} receiveValue: { videoProgressResponse in
XCTAssertEqual(videoProgressResponse.cursor, 124)
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Let's write the unit test for the happy path. Define a method with name testProgressForVideo_Success(). In the body of the unit test, we invoke the runProgressForVideoTest() method. Run the unit test by clicking the diamond in the gutter on the left. The unit test should pass without issues.
func testProgressForVideo_Success() throws {
runProgressForVideoTest()
}
Unit Testing the Unhappy Path
To unit test the unhappy path, we need to simulate a network failure. We add a parameter, urlErrorCode, to the runProgressForVideoTest() method. The parameter is of type URLError.Code? and it defaults to nil. By defaulting to nil, the unit test for the happy path doesn't break.
private func runProgressForVideoTest(urlErrorCode: URLError.Code? = nil) {
...
}
We use an if statement to safely unwrap the value of the urlErrorCode parameter. If the urlErrorCode parameter has a value, we simulate a network failure by creating a URLError object and passing it to the simulateFailure(endpoint:error:) method we created earlier. We move the stubAPI(endpoint:statusCode:response:) method invocation to the else clause.
private func runProgressForVideoTest(urlErrorCode: URLError.Code? = nil) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.progressForVideo(id: videoID)
if let code = urlErrorCode {
simulateFailure(
endpoint: endpoint,
error: URLError(code)
).store(in: &stubsDescriptors)
} else {
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: .file(name: endpoint.stub)
).store(in: &stubsDescriptors)
}
...
}
Before we write the unit tests for the unhappy path, we update the failure case in the completion handler of the sink(receiveCompletion:receiveValue:) method. We use another if statement to safely unwrap the value of the urlErrorCode parameter. If the urlErrorCode parameter has a value we fulfill the expectation and switch on the value of the urlErrorCode parameter. If the code is equal to notConnectedToInternet, we assert that the error, the associated value of the failure case, is equal to unreachable. If the code isn't equal to notConnectedToInternet, we assert that the error is equal to failedRequest.
switch completion {
case .finished:
expectation.fulfill()
case .failure(let error):
if let code = urlErrorCode {
expectation.fulfill()
switch code {
case .notConnectedToInternet:
XCTAssertEqual(error, .unreachable)
default:
XCTAssertEqual(error, .failedRequest)
}
} else {
XCTFail("Request Should Succeed")
}
}
We write two unit tests for the unhappy path, testProgressForVideo_Failure_NotConnected() and testProgressForVideo_Failure_CannotFindHost(). In both methods, we invoke the runProgressForVideoTest(urlErrorCode:) method. The only difference is the argument we pass to the method.
func testProgressForVideo_Success() throws {
runProgressForVideoTest()
}
func testProgressForVideo_Failure_NotConnected() throws {
runProgressForVideoTest(urlErrorCode: .notConnectedToInternet)
}
func testProgressForVideo_Failure_CannotFindHost() throws {
runProgressForVideoTest(urlErrorCode: .cannotFindHost)
}
Run the unit tests for the APIClient class to make sure they pass.
Increasing Code Coverage
By adding unit tests for the progressForVideo(id:) method, we have increased the coverage of the APIClient class. Let's take a look at the coverage of the request(_:) method. Despite our efforts, we still see a bit of red in the code coverage gutter on the right. This can be a bit misleading, though. Let's take a look at the gaps in the test suite.
We see a gap in the else clause of the guard statement of the tryMap operator. This is a false positive and a side effect of using an if-else statement. Remove the else clause and run the test suite one more time. Notice that the gap has disappeared.
guard statusCode.isSuccess else {
if statusCode == 401 {
throw APIError.unauthorized
}
throw APIError.failedRequest
}
Earlier in this series, I emphasized that code coverage is an estimate. It isn't perfect and that is something you need to be mindful of when writing unit tests. Don't rewrite code for the sake of code coverage. Even though the change we made in the else clause of the guard statement increases code coverage, I prefer the previous implementation as it is more readable in my view.
guard statusCode.isSuccess else {
if statusCode == 401 {
throw APIError.unauthorized
} else {
throw APIError.failedRequest
}
}
The gap below the do-catch statement of the tryMap operator is very similar. Xcode is correct that those lines of code are never executed, but that isn't surprising considering the implementation. Don't worry about such gaps in your test suite.
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
print("Unable to Decode Response \(error)")
throw APIError.invalidResponse
}
Let's take a look at the last gap in the catch clause of the outermost do-catch statement of the request(_:) method. We use an if statement to safely cast the error to APIError. We use an if statement because we use a defensive coding strategy. We could force cast the error to APIError because we know that the request(_:) method of the APIEndpoint enum only throws errors of type APIError. That might change in the future and that is why we code defensively. By coding defensively, you don't need to worry about accidentally breaking your implementation at some point in the future.
do {
...
} catch {
if let apiError = error as? APIError {
return Fail(error: apiError)
.eraseToAnyPublisher()
} else {
return Fail(error: APIError.unknown)
.eraseToAnyPublisher()
}
}
Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 87%. In the next episode, we write the last unit tests for the APIClient class.
What's Next?
Even though the coverage report says differently, we don't need to write additional unit tests for the request(_:) method. We can be confident that the request(_:) method behaves as expected. If you design your code and your unit tests with care, you can be sure that even hard to test scenarios won't surprise you. That is the beauty of unit tests.