Chapter 10 – Testing Network Code – Exercises Solution

Part 2: How to test the URLRequest configuration with a Spy

Test-Driven Development in Swift‘s Chapter 10 shows how to use TDD to implement the kernel of an application’s networking logic. The resulting implementation is necessarily simple; because of the book’s space constraints, it only services one API. At the end of the chapter, I suggested two exercises to improve the implementation: add a configurable base URL and another API call.

Previously, we saw how to refactor MenuFetcher adding a base URL parameter to its init using TDD and the Stub we wrote in Chapter 10.
In this bonus content post, we’ll see a different approach, using a Spy Test Double. You can learn all about Spies in Chapter 12.

Step 0: Action plan

Here’s a reminder of the action plan we devised in the previous post.

To add a base URL property to MenuFetcher, we’ll need to update its initialization code. We’ll then need to replace the hardcoded URL used to build the URLRequest with one created by interpolating the base URL value with the endpoint path.

To test this new behavior, we can build a Spy for the underlying NetworkFetching service and use it to record the URLRequest that MenuFetcher creates.

Base URL with Spy – Step 1: Create the Spy Test Double

Quick reminder: a Spy is a Test Double, a “test-specific equivalent” of a dependency of the System Under Test, aimed at allowing us to verify the indirect outputs the SUT produces.

Indirect outputs are anything that is not a return value of a method call,
such as a change in global state or calling a method on another component.

In our case, the indirect output is the URLRequest that MenuFetcher builds and passes to NetworkFetching for execution.

// NetworkFetchingSpy.swift
@testable import Albertos
import Combine
import Foundation

class NetworkFetchingSpy: NetworkFetching {

    private(set) var request: URLRequest?

    func load(_ request: URLRequest) -> AnyPublisher<Data, URLError> {
        self.request = request

        return Result<Data, URLError>
            .failure(URLError(.badURL))
            .publisher
            // Use a delay to simulate the real world async behavior
            .delay(for: 0.01, scheduler: RunLoop.main)
            .eraseToAnyPublisher()
    }
}

The Spy implementation is similar to the Stub from Chapter 10. Like the Stub, it, too, simulates the real network request by leveraging Result‘s capability to generate a Publisher and add a delay to introduce an async component in the code execution.

In the load(_:) implementation, the Spy stores the URLRequest input parameter in a private(set) variable so that consumers can read it but not accidentally modify it.

Unlike the Stub, though, the Spy doesn’t allow configuring the load(_:) return value but has a hardcoded return value. In this implementation I chose to return an error, but you could as well return a successful value by making MenuFetcher conform to Encodable and using:

.success(try! JSONEncoder().encode([MenuItem.fixture()]))

It doesn’t matter what the Spy returns because the test won’t be looking at it; they’ll only use the probes the Spy offers to measure the SUT side-effects.

Base URL with Spy – Step 2: Write a test for the URLRequest composition

Like in the Stub approach, let’s write a test with the code we have already and use it as a guide to make sure adding the base URL parameter to MenuFetcher doesn’t break its behavior.

Here’s how to use the Spy to check the URLRequest that MenuFetcher produces:

// MenuFetcherTests.swift
// ...
func testUsesGivenBaseURLInRequest() {
    let spy = NetworkFetchingSpy()
    let menuFetcher = MenuFetcher(networkFetching: spy)

    let expectation = XCTestExpectation(description: "Request succeeds")

    menuFetcher.fetchMenu()
        .sink(
            receiveCompletion: { _ in expectation.fulfill() },
            receiveValue: { _ in }
        )
        .store(in: &cancellables)

    wait(for: [expectation], timeout: 1)

    XCTAssert(spy.request?.url?.absoluteString == "https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk/menu_response.json")
}

What the test does is arranging MenuFetcher with NetworkFetchingSpy, acting on it via fetchMenu(), and asserting how it formed the URLRequest after waiting for the fetch to complete.

As I mentioned in the book, while using vanilla XCTest keeps your test suite setup lean, this testing framework from Apple has poor ergonomics when it comes to its assertion APIs. Using Nimble, discussed in the book’s Appendix B, makes the test clearer:

// MenuFetcherTests.swift
// ...
import Nimble

// ...
func testUsesGivenBaseURLInRequest() {
    let spy = NetworkFetchingSpy()
    let menuFetcher = MenuFetcher(networkFetching: spy)

    _ = menuFetcher.fetchMenu()

    expect(spy.request?.url?.absoluteString)
        .toEventually(equal("https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk/menu_response.json"))
}

Base URL with Spy — Step 3: Add the base URL parameter to MenuFetcher

Now that we have a test that verifies the behavior we aim to modify, we can set out to work with the peace of mind of quickly being able to verify whether our changes are correct.

The refactoring steps are the same as what we did in the previous article, eventually resulting in the following production and test code:

// MenuFetcher.swift
import Combine
import Foundation

class MenuFetcher: MenuFetching {

    let networkFetching: NetworkFetching
    let baseURL: URL

    init(
        baseURL: URL = URL(string: "https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk")!,
        networkFetching: NetworkFetching = URLSession.shared
    ) {
        self.baseURL = baseURL
        self.networkFetching = networkFetching
    }

    func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
        let request = URLRequest(url: baseURL.appendingPathComponent("menu_response.json"))

        return networkFetching.load(request)
            .decode(type: [MenuItem].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
// MenuFetcherTests.swift
// ...
func testUsesGivenBaseURLInRequest() throws {
    let spy = NetworkFetchingSpy()
    let menuFetcher = MenuFetcher(
        baseURL: try XCTUnwrap(URL(string: "https://test.fake")),
        networkFetching: spy
    )

    _ = menuFetcher.fetchMenu()

    expect(spy.request?.url?.absoluteString)
        .toEventually(equal("https://test.fake/menu_response.json"))
}

Spying vs. Stubbing

So, which approach is better to help us verify the URLRequest creation?
Stub or Spy?

As a reminder, here’s the test implemented using a Stub Test Double from the previous post, converted to use Nimble, too, to make the comparison fair:

// MenuFetcherTests.swift
// ...
func testUsesGivenBaseURLInRequest() throws {
    let baseURL = try XCTUnwrap(URL(string: "https://test.url"))
    let url = baseURL.appendingPathComponent("menu_response.json")
    let json = """
[
    { "name": "a name", "category": "a category", "spicy": true, "price": 1.0 }
]
"""
    let data = try XCTUnwrap(json.data(using: .utf8))
    let networkFetchingStub = NetworkFetchingStub(
        returning: .success(data),
        for: URLRequest(url: url)
    )
    let menuFetcher = MenuFetcher(baseURL: baseURL, networkFetching: networkFetchingStub)

    waitUntil { done in
        menuFetcher.fetchMenu()
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { _ in
                    // We don't care about the values received. We're only interested to know
                    // that we're here instead than in the failure branch, which means the
                    // request received by the Stub matched our expectation.
                    done()
                }
            )
            .store(in: &self.cancellables)
    }
}

The contrast between approaches is striking, and so is the winner.
The Spy.

Quoting from Gerard Meszaros’ xUnit Test Patterns, one of the classics in testing literature, a Spy offers ” an observation point that exposes the indirect outputs of the SUT so they can be verified.” That’s precisely what we need to test a behavior like calling an internal object with a specific input value.

The difference in the simplicity and readability of the two tests is why I follow Meszaros Test Doubles naming convention, rather than calling them all “Stub” or “Mock,” like I’ve seen in many codebases. Different behaviors require different testing strategies. Stubs, Spies, Fakes, Doubles, each are sharp tools well suited for different circumstances. Choosing the proper Test Double for the job will make your tests simple and communicate clear intent on the testing strategy.