Code coverage is a very helpful tool to detect gaps in a test suite. In the previous episode, we enabled code coverage to learn how to write better unit tests for the APIClient class. The coverage report shows that the episodes() method is fully covered. It also reveals that the private request(_:) method lacks coverage. Even though the request(_:) method is private to the APIClient class, we can write unit tests to indirectly test it and increase coverage of the APIClient class. That is the focus of this episode.
Increasing Code Coverage
Run the test suite and inspect the coverage of the APIClient class in the source editor. Xcode shows that we need to patch a number of gaps in the test suite. Let's take a look at the first gap.
Xcode points out that the else clause of the first guard statement of the tryMap operator isn't executed when the test suite is run. This isn't surprising since we expect the response of the request to always be of type HTTPURLResponse. It means that we aren't able to write a unit test for the scenario in which the response of a request isn't of type HTTPURLResponse.
We have two options. We can ignore the else clause of the first guard statement of the tryMap operator. That is a reasonable option because it isn't uncommon that a code path cannot be covered by unit tests. That is often a side effect of a defensive coding strategy. The other option is being less defensive by removing the guard statement. We can force cast the response of the request to HTTPURLResponse. That solves the problem, but it means we need to use the forced form of the type cast operator, as!.
private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
do {
let accessToken = accessTokenProvider.accessToken
let request = try endpoint.request(accessToken: accessToken)
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> T in
let statusCode = (response as! HTTPURLResponse).statusCode
...
}
.mapError { ... }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
} catch {
...
}
}
I tend to avoid forced type casting as much as possible so I would choose for the first option. If you are like me and prefer a defensive coding strategy, then choose the first option. I could live with either option, though.
The second gap in the unit tests for the APIClient class is related to another class of failures. We use a guard statement to inspect the status code of the response. The else clause of the guard statement is executed if the status code doesn't fall within the success range. In the else clause, we throw an unauthorized API error if the status code is equal to 401 and throw a failedRequest API error if the status code doesn't fall within the success range and isn't equal to 401. Writing unit tests for these scenarios is easier than you might think.
We start by refactoring the stubAPI(endpoint:isSuccess:) method of the APIClientTests class. We rename the isSuccess parameter to statusCode and change its type to Int. That change makes the method much more flexible.
func stubAPI(endpoint: Endpoint, statusCode: Int) -> HTTPStubsDescriptor {
...
}
In the response block we pass to the stub(condition:response:) function, we update the expression of the if statement. The if clause should be executed if the status code falls within the success range. We also replace the number literal with the value of the statusCode parameter. Because the second parameter of the fixture(filePath:status:headers:) function is of type Int32, we need to convert the status code to Int32.
func stubAPI(endpoint: Endpoint, statusCode: Int) -> HTTPStubsDescriptor {
stub { request in
request.url?.path == endpoint.path
} response: { request in
if (200..<300).contains(statusCode) {
if let filePath = OHPathForFile(endpoint.stub, type(of: self)) {
return fixture(filePath: filePath, status: Int32(statusCode), headers: [:])
} else {
fatalError("Unable to Find Stub in Bundle")
}
} else {
return HTTPStubsResponse(error: APIError.failedRequest)
}
}
}
In the else clause, we create an HTTPStubsResponse instance by invoking an initializer that accepts a Data object, a status code, and an optional dictionary of headers.
func stubAPI(endpoint: Endpoint, statusCode: Int) -> HTTPStubsDescriptor {
stub { request in
request.url?.path == endpoint.path
} response: { request in
if (200..<300).contains(statusCode) {
if let filePath = OHPathForFile(endpoint.stub, 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)
}
}
}
The stubAPI(endpoint:statusCode:) method is more flexible and allows us to write unit tests for a number of failures. Before we continue, I would like to improve the implementation with a few subtle changes.
Open TypeAliases.swift in the Configuration group. Declare a type alias with name StatusCode. StatusCode is a type alias for Int. This small change keeps the code you write readable and easier to understand.
import Foundation
typealias Headers = [String:String]
typealias StatusCode = Int
Revisit APIClientTests.swift and change the type of the second parameter of the stubAPI(endpoint:statusCode:) method to StatusCode.
func stubAPI(endpoint: Endpoint, statusCode: StatusCode) -> HTTPStubsDescriptor {
...
}
Add a Swift file to the Extensions group and name it StatusCode+Helpers.swift. Add an extension for StatusCode and define a computed property, isSuccess, of type Bool. The computed property returns true if the status code falls within the success range.
import Foundation
extension StatusCode {
var isSuccess: Bool {
(200..<300).contains(self)
}
}
Revisit the stubAPI(endpoint:statusCode:) method and update the expression of the if statement, using the isSuccess computed property we defined a moment ago.
func stubAPI(endpoint: Endpoint, statusCode: StatusCode) -> HTTPStubsDescriptor {
stub { request in
request.url?.path == endpoint.path
} response: { request in
if statusCode.isSuccess {
if let filePath = OHPathForFile(endpoint.stub, 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)
}
}
}
Open APIClient.swift and apply the same change to the request(_:) method.
private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
do {
let accessToken = accessTokenProvider.accessToken
let request = try endpoint.request(accessToken: accessToken)
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> T in
let statusCode = (response as! HTTPURLResponse).statusCode
guard statusCode.isSuccess else {
if statusCode == 401 {
throw APIError.unauthorized
} else {
throw APIError.failedRequest
}
}
...
}
.mapError { ... }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
} catch {
...
}
}
We need to make a few changes to the unit tests of the APIClient class. The runEpisodesTest(isSuccess:) method should accept a status code instead of a boolean. It passes the status code to the refactored stubAPI(endpoint:statusCode:) method. We replace the isSuccess parameter in the finished and failure cases of the switch statement with the value of the isSuccess computed property we defined earlier in this episode.
private func runEpisodesTest(statusCode: StatusCode) {
stubAPI(
endpoint: .episodes,
statusCode: statusCode
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Episodes")
apiClient.episodes()
.sink { completion in
switch completion {
case .finished:
if !statusCode.isSuccess {
XCTFail("Request Should Fail")
}
case .failure:
if statusCode.isSuccess {
XCTFail("Request Should Succeed")
}
}
expectation.fulfill()
} receiveValue: { episodes in
XCTAssertEqual(episodes.count, 10)
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
In the testEpisodes_Success() method, we pass 200 to the updated runEpisodesTest(statusCode:) method.
func testEpisodes_Success() throws {
runEpisodesTest(statusCode: 200)
}
We rename the testEpisodes_Failure() method to testEpisodes_Unauthorized(). In its body, we pass 401 to the updated runEpisodesTest(statusCode:) method.
func testEpisodes_Unauthorized() throws {
runEpisodesTest(statusCode: 401)
}
We define another unit test with name testEpisodes_NotFound(). In its body, we invoke the runEpisodesTest(statusCode:) method, passing in 404 as the status code.
func testEpisodes_NotFound() throws {
runEpisodesTest(statusCode: 404)
}
Run the test suite. The unit tests for the APIClient class should continue to pass without issues. Revisit the APIClient class and note that its coverage has improved quite a bit thanks to the changes we made. Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 65%.
Writing Sound Unit Tests
The unit tests we added have improved the coverage of the APIClient class, but we can make one more change that improves the value of the unit tests. Open APIClientTests.swift and navigate to the runEpisodesTest(statusCode:) method. In the failure case of the switch statement, we need to verify that the error the publisher emits matches the error we expect. The failure case has an associated value of type Error. We use a switch statement to switch on the value of the statusCode parameter and assert that the error matches the error we expect.
private func runEpisodesTest(statusCode: StatusCode) {
stubAPI(
endpoint: .episodes,
statusCode: statusCode
).store(in: &stubsDescriptors)
let expectation = self.expectation(description: "Fetch Episodes")
apiClient.episodes()
.sink { completion in
switch completion {
case .finished:
if !statusCode.isSuccess {
XCTFail("Request Should Fail")
}
case .failure(let error):
if statusCode.isSuccess {
XCTFail("Request Should Succeed")
}
switch statusCode {
case 401:
XCTAssertEqual(error, APIError.unauthorized)
default:
XCTAssertEqual(error, APIError.failedRequest)
}
}
expectation.fulfill()
} receiveValue: { episodes in
XCTAssertEqual(episodes.count, 10)
}.store(in: &subscriptions)
waitForExpectations(timeout: 10.0)
}
Run the test suite one more time to make sure the unit tests for the APIClient class are still green.
What's Next?
Many developers consider unit tests an afterthought, but that is a misconception. Unit tests are just as important as the other code you write. A test suite is an integral aspect of a software project. In this episode, I showed you that unit tests can be readable, structured, and easy to extend and maintain. It is true that it takes a bit more time, but you win that time back down the line. In the next episode, we continue to extend the unit tests for the APIClient class to improve its coverage.