You learned in the previous episode that it is fine to ignore some of the gaps Xcode finds in your test suite. Even though the goal isn't to fill every gap, we need to write a few more unit tests for the APIClient class.

Rinse and Repeat

Open APIClient.swift and run the test suite. Xcode shows that we need to write unit tests for the signIn(email:password:) method and the updateProgressForVideo(id:cursor:) method. Because the request(_:) method is sufficiently covered, we can keep the unit tests for these methods short and simple.

Let's start with the signIn(email:password:) method. Open APIClientTests.swift and navigate to the declaration of the Endpoint enum. We extend the Endpoint enum by adding a case with name auth. The auth case defines an associated value, isSuccess, of type Bool. This is a technique we haven't used before. I explain how it works in a moment.

fileprivate enum Endpoint {

    // MARK: - Cases

    case auth(isSuccess: Bool)
    case episodes
    case video(id: String)
    case progressForVideo(id: String)
    case deleteVideoProgress(id: String)

    ...

}

We need to update the computed path and stub properties. Updating the computed path property is straightforward. Notice that we don't use the associated value to create the path for the endpoint.

var path: String {
    let path: String = {
        switch self {
        case .auth:
            return "auth"
        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)"
}

Before we update the computed stub property, we add two files to the Stubs group, auth-success.json and auth-failure.json. As before, add the files to the CocoacastsTests target, not the Cocoacasts target.

Revisit the computed stub property. Add the auth case to the switch statement. We use the associated value of the auth case to define the name of the stub. If isSuccess is equal to true, we return auth-success.json. If isSuccess is equal to false, we return auth-failure.json.

var stub: String {
    switch self {
    case .auth(isSuccess: let isSuccess):
        return isSuccess
            ? "auth-success.json"
            : "auth-failure.json"
    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.")
    }
}

We write two unit tests for the signIn(email:password:) method, one for the happy path and one for the unhappy path. As before, we first define a private helper method that each unit test invokes to improve readability and minimize code duplication. Define a private method with name runSignInTest(isSuccess:). The method defines one parameter, isSuccess, of type Bool.

// MARK: - Tests for Sign In

private func runSignInTest(isSuccess: Bool) {

}

We first create an APIClient instance. Remember that the initializer requires an access token provider. Because the endpoint doesn't require authorization, we pass an access token provider to the initializer that doesn't return an access token.

private func runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )
}

The next step is stubbing the Cocoacasts API. We define the endpoint of the Cocoacasts API we need to stub and store it in a constant with name endpoint. We pass the value of the isSuccess parameter as the associated value of the auth case.

private func runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )

    let endpoint = Endpoint.auth(isSuccess: isSuccess)
}

We stub the Cocoacasts API by invoking the stubAPI(endpoint:statusCode:response:) method. The first argument is the Endpoint object. The second argument is the status code of the response, 200 if isSuccess is equal to true and 401 if isSuccess is equal to false. The third argument is the stub for the endpoint. We store the stubs descriptor in the array of stubs descriptors.

private func runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )

    let endpoint = Endpoint.auth(isSuccess: isSuccess)

    stubAPI(
        endpoint: endpoint,
        statusCode: isSuccess ? 200 : 401,
        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, signIn(email:password). We subscribe to the publisher the signIn(email:password) 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 runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )

    let endpoint = Endpoint.auth(isSuccess: isSuccess)

    stubAPI(
        endpoint: endpoint,
        statusCode: isSuccess ? 200 : 401,
        response: .file(name: endpoint.stub)
    ).store(in: &stubsDescriptors)

    let expectation = self.expectation(description: "Sign In")

    apiClient.signIn(email: "bart@cocoacasts.com", password: "123456789")
        .sink(receiveCompletion: { completion in

        }, receiveValue: { signInResponse in

        }).store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

The completion handler of the sink(receiveCompletion:receiveValue:) method should look familiar. We switch on the Completion object. Let's start with the finished case. If isSuccess is equal to true, we fulfill the expectation. If isSuccess is equal to false, we invoke the XCTFail(_:file:line:) function. We do the opposite in the failure case. If isSuccess is equal to true, we invoke the XCTFail(_:file:line:) function. If isSuccess is equal to false, we fulfill the expectation.

private func runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )

    let endpoint = Endpoint.auth(isSuccess: isSuccess)

    stubAPI(
        endpoint: endpoint,
        statusCode: isSuccess ? 200 : 401,
        response: .file(name: endpoint.stub)
    ).store(in: &stubsDescriptors)

    let expectation = self.expectation(description: "Sign In")

    apiClient.signIn(email: "bart@cocoacasts.com", password: "123456789")
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                if isSuccess {
                    expectation.fulfill()
                } else {
                    XCTFail("Request Should Fail")
                }
            case .failure:
                if isSuccess {
                    XCTFail("Request Should Succeed")
                } else {
                    expectation.fulfill()
                }
            }
        }, receiveValue: { signInResponse in

        }).store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

If the request is successful, then we also expect the value handler to be invoked. We set the expectedFulfillmentCount property of the expectation to 2 if isSuccess is equal to true. In the value handler, we invoke the expectation.

private func runSignInTest(isSuccess: Bool) {
    let apiClient = APIClient(
        accessTokenProvider: MockAccessTokenProvider(accessToken: nil)
    )

    let endpoint = Endpoint.auth(isSuccess: isSuccess)

    stubAPI(
        endpoint: endpoint,
        statusCode: isSuccess ? 200 : 401,
        response: .file(name: endpoint.stub)
    ).store(in: &stubsDescriptors)

    let expectation = self.expectation(description: "Sign In")
    expectation.expectedFulfillmentCount = isSuccess ? 2 : 1

    apiClient.signIn(email: "bart@cocoacasts.com", password: "123456789")
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                if isSuccess {
                    expectation.fulfill()
                } else {
                    XCTFail("Request Should Fail")
                }
            case .failure:
                if isSuccess {
                    XCTFail("Request Should Succeed")
                } else {
                    expectation.fulfill()
                }
            }
        }, receiveValue: { signInResponse in
            expectation.fulfill()
        }).store(in: &subscriptions)

    waitForExpectations(timeout: 10.0)
}

We could also inspect the SignInResponse object that is passed to the value handler. As you can see, there is more than one way to write a unit test.

We still need to write the unit tests for the signIn(email:password:) method. We add two unit tests, testSignIn_Success() and testSignIn_Failure(). Each unit test invokes the runSignInTest(isSuccess:)method. The difference is the value of theisSuccessparameter,trueandfalse` respectively.

func testSignIn_Success() throws {
    runSignInTest(isSuccess: true)
}

func testSignIn_Failure() throws {
    runSignInTest(isSuccess: false)
}

Run the test suite to make sure the newly added unit tests pass. Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 90%.

Update Progress for Video

The unit tests for the updateProgressForVideo(id:cursor:) method are similar to those for the progressForVideo(id:) method. Pause the video and add the unit tests for the updateProgressForVideo(id:cursor:) method. We write two unit tests, one for the happy path and one for the unhappy path.

Open APIClientTests.swift and extend the Endpoint enum by adding a case with name updateProgressForVideo. The updateProgressForVideo case is similar to the progressForVideo case. It too defines an associated value, id, of type String. The associated value is the identifier of the video.

fileprivate enum Endpoint {

    // MARK: - Cases

    case auth(isSuccess: Bool)
    case episodes
    case video(id: String)
    case progressForVideo(id: String)
    case updateProgressForVideo(id: String)
    case deleteVideoProgress(id: String)

	...

}

Updating the computed path property is trivial since the Endpoint enum already supports fetching and deleting the progress for a video.

var path: String {
    let path: String = {
        switch self {
        case .auth:
            return "auth"
        case .episodes:
            return "episodes"
        case .video(id: let id):
            return "videos/\(id)"
        case .progressForVideo(id: let id),
             .updateProgressForVideo(id: let id),
             .deleteVideoProgress(id: let id):
            return "videos/\(id)/progress"
        }
    }()

    return "/api/v1/\(path)"
}

Add a file with name update-progress-for-video.json to the Stubs group. The stub contains a cursor and a video ID.

To update the computed stub property, we add the updateProgressForVideo case to the switch statement and return the name of the file we added to the Stubs group.

var stub: String {
    switch self {
    case .auth(isSuccess: let isSuccess):
        return isSuccess
            ? "auth-success.json"
            : "auth-failure.json"
    case .episodes:
        return "episodes.json"
    case .video:
        return "video.json"
    case .progressForVideo:
        return "progress-for-video.json"
    case .updateProgressForVideo:
        return "update-progress-for-video.json"
    case .deleteVideoProgress:
        fatalError("This endpoint doesn't define a stub. It returns a 204 response on success.")
    }
}

Define a private helper method, runUpdateProgressForVideoTest(isSuccess:). The method defines one parameter, isSuccess, of type Bool.

// MARK: - Tests for Update Progress for Video

private func runUpdateProgressForVideoTest(isSuccess: Bool) {
	
}

Because the endpoint requires authorization, we create a mock access token provider that returns a valid access token and use it to create an APIClient instance.

private func runUpdateProgressForVideoTest(isSuccess: Bool) {
    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 runUpdateProgressForVideoTest(isSuccess: Bool) {
    let accessTokenProvider = MockAccessTokenProvider(accessToken: "123456")
    let apiClient = APIClient(accessTokenProvider: accessTokenProvider)

    let endpoint = Endpoint.updateProgressForVideo(id: videoID)
}

To stub the Cocoacasts API, we inspect the value of the isSuccess parameter. If isSuccess is equal to true, we invoke the stubAPI(endpoint:statusCode:response:) method, passing in the Endpoint object as the first argument, the status code of a successful request as the second argument, and the stub for the endpoint as the third argument. We store the stubs descriptor the stubAPI(endpoint:statusCode:response:) method returns in the array of stubs descriptors.

if isSuccess {
    stubAPI(
        endpoint: endpoint,
        statusCode: 200,
        response: .file(name: endpoint.stub)
    ).store(in: &stubsDescriptors)
} else {
	
}

If isSuccess is equal to false, we invoke the simulateFailure(endpoint:error:) method. The first argument is the Endpoint object. The second argument is a URLError object. We store the stubs descriptor the simulateFailure(endpoint:error:) method returns in the array of stubs descriptors.

if isSuccess {
    stubAPI(
        endpoint: endpoint,
        statusCode: 200,
        response: .file(name: endpoint.stub)
    ).store(in: &stubsDescriptors)
} else {
    simulateFailure(
        endpoint: endpoint,
        error: URLError(.notConnectedToInternet)
    ).store(in: &stubsDescriptors)
}

Before we invoke the method under test, we define an expectation for the asynchronous unit test.

let expectation = self.expectation(description: "Update Progress for Video")

apiClient.updateProgressForVideo(id: videoID, cursor: 123)

We subscribe to the publisher the updateProgressForVideo(id:cursor:) 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.

apiClient.updateProgressForVideo(id: videoID, cursor: 123)
    .sink { completion in
        
    } receiveValue: { videoProgressResponse in
        
    }.store(in: &subscriptions)

waitForExpectations(timeout: 10.0)

The implementation of the completion handler is identical to that of the runSignInTest(isSuccess:) method. In the value handler, we assert that the value of the cursor property of the VideoProgressResponse object is equal to 124.

apiClient.updateProgressForVideo(id: videoID, cursor: 123)
    .sink { completion in
        switch completion {
        case .finished:
            if isSuccess {
                expectation.fulfill()
            } else {
                XCTFail("Request Should Fail")
            }
        case .failure:
            if isSuccess {
                XCTFail("Request Should Succeed")
            } else {
                expectation.fulfill()
            }
        }
    } receiveValue: { videoProgressResponse in
        XCTAssertEqual(videoProgressResponse.cursor, 124)
    }.store(in: &subscriptions)

We write two unit tests, testUpdateProgressForVideo_Success() and testUpdateProgressForVideo_Failure(). In each method, we invoke the runUpdateProgressForVideoTest(isSuccess:) method, passing in true and false respectively.

func testUpdateProgressForVideo_Success() throws {
    runUpdateProgressForVideoTest(isSuccess: true)
}

func testUpdateProgressForVideo_Failure() throws {
    runUpdateProgressForVideoTest(isSuccess: false)
}

Run the test suite one more time to make sure every unit test passes. Select the Report Navigator and open the coverage report. The coverage of the APIClient class increased to 93%.

Navigate to the APIClient class and inspect the coverage in the gutter on the right. Notice that the only remaining gaps are the ones we discussed in the previous episode. We won't be writing any more unit tests for the APIClient class for now.

What's Next?

In the past few episodes, you learned that there are multiple ways to write unit tests. Even though there are multiple ways you can choose from, there are good one and less good ones. Make sure you are critical when you write unit tests. Don't fall into the trap of writing unit tests that provide a false sense of confidence. Remember that confidence in your code is the most important outcome of a test suite. Code coverage is an estimate and imperfect. Increasing code coverage shouldn't be a goal on its own. It is the logical side effect of a healthy test suite.