One of the key benefits of a robust test suite is its ability to quickly and easily test scenarios that are uncommon or hard to reproduce. In this and the next episode, we write unit tests for several scenarios that are difficult to test manually. Manual testing has value, but it is time-consuming and it can be tedious to test edge cases.
Invalid Response
Open APIClient.swift and run the test suite by choosing Test from Xcode's Product menu. We should still be in the green. Navigate to the request(_:) method and enable code coverage in the source editor. Xcode shows which unit test we need to write next. The closure we pass to the tryMap operator throws an APIError.invalidResponse error if the JSON decoder fails to decode the response.
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
print("Unable to Decode Response \(error)")
throw APIError.invalidResponse
}
While it is pretty painful to test that scenario manually, it is trivial to write a unit test that covers the scenario in which the Cocoacasts API returns an invalid response. While we don't expect to ever receive a response the JSON decoder isn't able to decode, networks are unreliable. The application should to be able to handle and recover from such a scenario.
Open APIClientTests.swift. Let's write a few unit tests for the video(id:) method of the APIClient class. We first extend the Endpoint enum by adding a case with name video. The video 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 deleteVideoProgress(id: String)
// MARK: - Properties
...
}
We need to update the computed path and stub properties. Updating the computed path property isn't difficult. We use the associated value to build the path for the endpoint.
var path: String {
let path: String = {
switch self {
case .episodes:
return "episodes"
case .video(id: let id):
return "videos/\(id)"
case .deleteVideoProgress(id: let id):
return "videos/\(id)/progress"
}
}()
return "/api/v1/\(path)"
}
Before we update the computed stub property, we add a stub for the endpoint. We add a file with name video.json to the Stubs group. The file contains a response that contains the details of a video. Make sure you add video.json to the CocoacastsTests target, not the Cocoacasts target.
We can now update the computed stub property. We add the video case to the switch statement and return the name of the file we added a moment ago.
var stub: String {
switch self {
case .episodes:
return "episodes.json"
case .video:
return "video.json"
case .deleteVideoProgress:
fatalError("This endpoint doesn't define a stub. It returns a 204 response on success.")
}
}
We first write a unit test for the happy path applying the same pattern we applied earlier. To improve readability and reduce code duplication, we define a private helper method that each unit test invokes. This should look familiar. Name the method runVideoTest(isSuccess:). It defines a single parameter, isSuccess, of type Bool.
// MARK: - Tests for Video
private func runVideoTest(isSuccess: Bool) {
}
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 runVideoTest(isSuccess: Bool) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
}
You probably know what we need to do next. We define the endpoint of the Cocoacasts API we need to stub and store it in a constant with name endpoint.
private func runVideoTest(isSuccess: Bool) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.video(id: videoID)
}
We pass the Endpoint object as the first argument of the stubAPI(endpoint:statusCode:response:) method. The second argument, the status code of the response, is 200. The third argument is defined by the value of the isSuccess parameter. If the request is successful, we return the stub for the endpoint. If the request is unsuccessful, we return an empty Data object. We store the stubs descriptor in the array of stubs descriptors. That's it.
private func runVideoTest(isSuccess: Bool) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.video(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: isSuccess ? .file(name: endpoint.stub) : .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, video(id:). We subscribe to the publisher the video(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.
private func runVideoTest(isSuccess: Bool) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.video(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: isSuccess ? .file(name: endpoint.stub) : .data(data: Data())
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Video")
apiClient.video(id: videoID)
.sink { completion in
} receiveValue: { video in
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
We can copy the completion handler of the runEpisodesTest(statusCode:) method. We need to make a few tweaks, though. The if statements inspect the value of the isSuccess parameter instead of the value of the computed isSuccess property of the status code. In the failure case, we expect the error to be equal to APIError.invalidResponse. We leave the value handler empty for now.
private func runVideoTest(isSuccess: Bool) {
let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
let apiClient = APIClient(accessTokenProvider: accessTokenProvider)
let endpoint = Endpoint.video(id: videoID)
stubAPI(
endpoint: endpoint,
statusCode: 200,
response: isSuccess ? .file(name: endpoint.stub) : .data(data: Data())
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Video")
apiClient.video(id: videoID)
.sink { completion in
switch completion {
case .finished:
if !isSuccess {
XCTFail("Request Should Fail")
}
case .failure(let error):
if isSuccess {
XCTFail("Request Should Succeed")
}
XCTAssertEqual(error, APIError.invalidResponse)
}
expectation.fulfill()
} receiveValue: { video in
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
We need to write two unit tests that invoke the runVideoTest(isSuccess:) method, testVideo_Success() and testVideo_Failure(). The only difference is the value of the isSuccess parameter, true and false respectively.
func testVideo_Success() throws {
runVideoTest(isSuccess: true)
}
func testVideo_Failure() throws {
runVideoTest(isSuccess: false)
}
Run the unit tests for the APIClient class to make sure they pass. Navigate to the APIClient class and inspect the code coverage of the request(_:) method. Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 80%.
Configuring the Expectation
The unit tests we wrote for the video(id:) method pass so we could move on. Before we do, I would like to show you how we can improve the value of the unit tests we wrote. Open APIClientTests.swift and navigate to the runVideoTest(isSuccess:) method. The value handler of the sink(receiveCompletion:receiveValue:) method is empty at the moment. We know that the value handler should only be invoked if the request is successful. We can verify this using the expectation of the unit test.
The XCTestExpectation class declares a property that defines how often the expectation is expected to be fulfilled. The default value is 1. The unit test fails if the expectation isn't fulfilled or if the expectation is fulfilled more than once. We can use the expectedFulfillmentCount property to verify that the value handler is invoked if the request is successful. We set the value of the expectedFulfillmentCount property to 2 if the value of the isSuccess parameter is equal to true. If the request is expected to fail, we set the value of the expectedFulfillmentCount property to 1.
let expectation = self.expectation(description: "Fetch Video")
expectation.expectedFulfillmentCount = isSuccess ? 2 : 1
In the value handler, we fulfill the expectation. We also verify that that the value of the video's duration property is equal to 279.
apiClient.video(id: videoID)
.sink { completion in
...
expectation.fulfill()
} receiveValue: { video in
expectation.fulfill()
XCTAssertEqual(video.duration, 279)
}.store(in: &subscriptions)
Run the test suite one more time to make sure the unit tests for the APIClient class still pass. Even though the changes we made to the value handler haven't increased the coverage of the APIClient class, they increased the value of the unit tests we wrote and that in turn gives us more confidence that the code we wrote is sound. Remember that it is important to carefully craft the unit tests you write.
What's Next?
In the next episode, we take a look at another edge case and I show you how useful unit tests are to feel confident that the code you write can handle edge cases that are difficult to test manually. You also learn how unit tests can help you find bugs. It has happened more than once that I come across a bug while writing unit tests.