© Gio Lodi 2021
G. LodiTest-Driven Development in Swifthttps://doi.org/10.1007/978-1-4842-7002-8_10

10. Testing Network Code

Gio Lodi1  
(1)
Mount Martha, VIC, Australia
 

How do you write tests for code interfacing with the network?

By sidestepping it with a Stub, to simulate different behaviors and avoid flaky tests.

A common problem faced when trying to test networking code is that the network is unpredictable and slow, resulting in non-deterministic tests — they may pass one time and fail the next.

In this chapter, we’ll see how to use the Dependency Inversion Principle and Stub Test Double techniques to decouple our code from the network’s real-world constraints.

The remote menu loading feature is almost complete. We built a view capable of updating when data comes in from the remote API and made the domain model Decodable. It’s now time to implement the logic loading the data from the network. Let’s call the object responsible for this MenuFetcher.

Apple provides an advanced and versatile system for making network calls via URLSession in the Foundation framework, and we shall use it for our networking implementation. We also know that MenuFetcher has to conform to MenuFetching, the abstraction we defined in Chapter 7. The protocol is the contract between ViewModel and lower-level components on how to fetch the menu from a remote resource.

MenuFetching defines the behavior we need to implement. When the request succeeds, it should return the received Data converted into a [MenuItem]. When the request fails, it should send through the error it received. Let’s write the test list for MenuFetcher based on that spec:
// MenuFetcherTests.swift
@testable import Albertos
import XCTest
class MenuFetcherTests: XCTestCase {
    func testWhenRequestSucceedsPublishesDecodedMenuItems() {}
    func testWhenRequestFailsPublishesReceivedError() {}
}

A natural starting point here would be to hit the remote API using URLSession and assert the result matches our expectation.

Directly hitting the network in the unit tests is undesirable. It results in slow and flaky tests and makes it hard to exercise all of the different scenarios. Let see why and what to do about it.

Why You Shouldn’t Make Network Requests in Your Unit Tests

To understand the limitations of testing direct networking, let’s write such a test ourselves.

So far in the book, we’ve used Wishful Coding for most of our new code. Wishful Coding is useful to get started writing code when you’re not sure what shape it should take, but we’ve worked with enough Combine by now to have a clear idea of the structure we should use. Let’s begin with an incomplete implementation of MenuFetcher so that we can write a full test without compiler errors:
// MenuFetcher.swift
import Combine
class MenuFetcher: MenuFetching {
    func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
        return Future { $0(.success([])) }.eraseToAnyPublisher()
    }
}
The fetching request returns an AnyPublisher<[MenuItem], Error>, which sends asynchronous updates. We already saw in Chapter 7 that we can test this kind of code by subscribing to it with sink and using an XCTestExpectation to wait for a new value:
// MenuFetcherTests.swift
// ...
func testWhenRequestSucceedsPublishesDecodeMenuItems() {
    let menuFetcher = MenuFetcher()
    let expectation = XCTestExpectation(description: "Publishes decoded [MenuItem]")
    menuFetcher.fetchMenu()
        .sink(
            receiveCompletion: { _ in },
            receiveValue: { items in
                // How to test if the value of items is // correct?
                expectation.fulfill()
            }
        )
        .store(in: &cancellables)
    wait(for: [expectation], timeout: 1)
}

Writing this test brings up a question: how can we assert that MenuFetcher publishes the correct value?

Because the data comes from the remote API, the only way to know what values to expect is to look there. Once we know what values the backend holds, we can use some broad-stroke assertions, for example, checking that the count is the same and that the first and last elements match.

You can find a fake version of the API at https://github.com/mokagio/tddinswift_fake_api/. In theme with moving in small steps and only write as little code as necessary, this fake API is actually merely a container for JSON files. The endpoint for the menu list is nothing more than the URL of the fake response static JSON.

Fetching static JSONs from a remote resource is an excellent way to simulate the real-world behavior of interfacing with a fully fledged API backend:
// MenuFetcherTests.swift
// ...
menuFetcher.fetchMenu()
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { items in
            XCTAssertEqual(items.count, 42)
            XCTAssertEqual(items.first?.name, "spaghetti carbonara")
            XCTAssertEqual(items.last?.name, "pasta all'arrabbiata")            expectation.fulfill()
        }
    )

It’s okay to use a looser set of assertions in this instance because we already wrote a comprehensive test for the decoding in the previous chapter. If that wasn’t the case, and the JSON decoding logic was nontrivial, I’d encourage you to at least check that one element in the array has all the expected properties from its JSON counterpart.

Running the tests with the Cmd U keyboard shortcut will show a failure. No surprise there: our initial implementation returns a hardcoded empty array.

Let’s implement a real call to the network using URLSession and Combine:
// MenuFetcher.swift
import Combine
import Foundation
class MenuFetcher: MenuFetching {
    func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
        let url = URL(string:
"https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk/menu_response.json")!
        URLSession.shared
            .dataTaskPublisher(for: URLRequest(url: url))
            .map { $0.data }
            .decode(type: [MenuItem].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

The test should now pass. I say should because there are a few factors that might make it fail.

First of all, if the menu in the backend changes, the test will fail. For example, if Alberto adds a new dish, the expectation on the count will fail.

Depending on live values makes your unit tests non-deterministic and will require constant updates to the suite. The whole point of having a dedicated API for the menu is to make updating it easier; we can expect it to change many times in the future. Every time the remote menu changes, the test could fail. You might be working on a new feature and be surprised by this test failing out of the blue.

Another factor that makes this test non-deterministic is the dependency on the network. The API might respond slowly because of a poor connection or not reply at all if there is no Internet available. If that happens, the test will fail, but the cause is outside your source code.

Depending on your connection’s quality, the 1 second used in wait(for:, timeout:) might not be enough, and the test will time out. You might need to use a longer time interval to make the test robust against slow networks, but this comes at the cost of having a slow test suite.

Even with a longer timeout, you are still susceptible to other network failures. To verify this, delete the app from your Simulator to remove any cached value, disconnect your Mac from the Internet by turning the Wi-Fi off or unplugging the Ethernet cable, and rerun the tests. The test will fail with
  • Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: “Publishes decoded [MenuItem].”

Interfacing with the network slows down your unit tests with the overhead of its response time. One of the advantages of Test-Driven Development is its fast feedback cycle but performing real network requests from your tests slows it down, making the whole process less effective.

Even if you’re willing to go past these limitations, how can you test the code’s behavior when the API request fails?

The way forward is to apply the Dependency Inversion Principle.

Force-Unwrapping Optionals

You might have noticed the code obtains the URL by calling URL(string:) with a hardcoded value. URL(string:) returns an Optional value, and we use the ! suffix to force-unwrap it.

Force-unwrapping an Optional is a way to tell the Swift compiler that we trust its value will never be none at runtime and bypass the need to manually unwrap it using if let or guard let.

It’s usually best to avoid force-unwrapping: if by any chance the value turns out to be none at runtime, the app will crash. It’s better to write a few extra lines of code to explicitly unwrap Optionals and handle the case when they don’t have a value.

While it’s wise to avoid force-unwrapping, this URL(string:) call would return none only if the developer who wrote the hardcoded value made a mistake. As Chris Eidhof, Daniel Eggert, and Florian Kugler argue, this use of force-unwrapping is a useful way to expose programmer errors by making the application crash early. Since we practice TDD and most of our code is covered by tests, the chance of a crash caused by a programmer error like this one going unnoticed is slim, so we can confidently use force-unwrap in these particular cases.

How to Decouple the Unit Tests from the Network

MenuFetcher depends on URLSession to access the network. As we’ve seen in Chapter 7, high-level components should not depend on low-level ones; they should both depend on abstractions. That’s the Dependency Inversion Principle.

The network is just another dependency of our system. We can place an abstraction layer between MenuFetcher and URLSession and build a Stub Test Double to simulate the indirect input the network provides to the SUT.

We used the URLSession dataTaskPublisher(for:) method to perform the network request, which returns an AnyPublisher<(Data, URLResponse), URLError>. The abstraction we’ll define should have a similar signature, with the difference that our business logic doesn’t need to read the URLResponse:
// NetworkFetching.swift
import Combine
import Foundation
protocol NetworkFetching {
    func load(_ request: URLRequest) -> AnyPublisher<Data, URLError>
}
We can make URLSession conform to the NetworkFetching in a thin extension:
// URLSession+NetworkFetching.swift
import Combine
import Foundation
extension URLSession: NetworkFetching {
    func load(_ request: URLRequest) -> AnyPublisher<Data, URLError> {
        return dataTaskPublisher(for: request)
            .map { $0.data }
            .eraseToAnyPublisher()
    }
}

It’s crucial to keep the code conforming to the protocol as simple as possible and free from custom logic because we won’t be testing it. With an implementation like that, all the code is outside our control because it’s Apple code. It’s not our responsibility to test URLSession, and we can be relatively confident it will always behave as expected.1

Let’s apply DIP and refactor MenuFetcher to depend on the NetworkFetching instead of calling URLSession directly:
// MenuFetcher.swift
import Combine
import Foundation
class MenuFetcher: MenuFetching {
    let networkFetching: NetworkFetching
    init(networkFetching: NetworkFetching = URLSession.shared) {
        self.networkFetching = networkFetching
    }
    func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
        let url = URL(string:
"https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk/menu_response.json")!
        networkFetching
            .load(URLRequest(url: url))
            .decode(type: [MenuItem].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}

The tests are still passing, with the caveat and limitations discussed earlier. It’s now time to build a Stub for NetworkFetching and use it to simulate the different behaviors we want to test.

Simulate Network Requests Using a Stub

To write the tests, we need a way to provide them with a predefined input to check against: either Data to decode or a URLError value. We can build a Stub Test Double for this, using the same technique we learned in Chapter 8:
// NetworkFetchingStub.swift
@testable import Albertos
import Combine
import Foundation
class NetworkFetchingStub: NetworkFetching {
    private let result: Result<Data, URLError>
    init(returning result: Result<Data, URLError>) {
        self.result = result
    }
    func load(_ request: URLRequest) -> AnyPublisher<Data, URLError> {
        return result.publisher
            // Use a delay to simulate the real world async             // behavior
            .delay(for: 0.01, scheduler: RunLoop.main)
            .eraseToAnyPublisher()
    }
}
We can use the Stub to decouple from the network’s non-deterministic nature and have better control over the test’s input:
// MenuFetcherTests.swift
// ...
func testWhenRequestSucceedsPublishesDecodedMenuItems() throws {
    let json = """
[
    { "name": "a name", "category": "a category", "spicy": true },
    { "name": "another name", "category": "a category", "spicy": true }
]
"""
    let data = try XCTUnwrap(json.data(using: .utf8))
    let menuFetcher = MenuFetcher(networkFetching: NetworkFetchingStub(returning: .success(data)))
    let expectation = XCTestExpectation(description: "Publishes decoded [MenuItem]")
    menuFetcher.fetchMenu()
        .sink(
            receiveCompletion: { _ in },
            receiveValue: { items in
                XCTAssertEqual(items.count, 2)
                XCTAssertEqual(items.first?.name, "a name")
                XCTAssertEqual(items.last?.name, "another name")
                expectation.fulfill()
            }
        )
        .store(in: &cancellables)
    wait(for: [expectation], timeout: 1)
}

Using the Stub removes the dependency on the network making them faster and more robust. If you try running the tests offline now, you’ll see they pass.

A Stub in a networking test also helps to clarify where the values used in the assertions come from: their definition is now part of the test itself.

Let’s move on to the test for failure management:
// MenuFetcherTests.swift
// ...
func testWhenRequestFailsPublishesReceivedError() {
    let expectedError = URLError(.badServerResponse)
    let menuFetcher = MenuFetcher(networkFetching: NetworkFetchingStub(returning: .failure(expectedError)))
    let expectation = XCTestExpectation(description: "Publishes received URLError")
    menuFetcher.fetchMenu()
        .sink(
            receiveCompletion: { completion in
                guard case .failure(let error) = completion else { return }
                XCTAssertEqual(error as? URLError, expectedError)
                expectation.fulfill()
            },
            receiveValue: { items in
                XCTFail("Expected to fail, succeeded with (items)")
            }
        )
        .store(in: &cancellables)
    wait(for: [expectation], timeout: 1)
}

This test passes already. The reason for it is that we’re testing the default behavior of what Combine’s extension of URLSession already gives us.

Even though one could argue that this test is redundant, I’d encourage you to keep it. Ensuring that our code handles failures properly is key to building robust software with a great user experience. Having an explicit test for that signals that we take error handling seriously. The fact error handling is all done for us by Combine under the hood is an implementation detail, and it might change in the future. A dedicated test ensures that if error handling changes, you’ll know about it immediately.

A Third-Party Alternative

This book only treats vanilla XCTest, but there is a third-party library worth mentioning when it comes to testing networking code: OHHTTPStubs. This library allows you to stub network requests directly on top of URLSession without the need for a dedicated abstraction layer, making it an excellent choice to add tests to big legacy codebases that are hard to change before starting to refactor them.

You can test networking logic by insulating from the network itself and focusing only on how your app makes requests and handles their result.

By defining an abstraction to hide the inner workings of URLSession, you can simulate any response from the network and write comprehensive tests using a Stub Test Double while also removing the intrinsic non-deterministic effect making real HTTP requests introduces.

Keep the code conforming to the abstraction layer to a minimum, ideally using only methods provided by Apple. You can be confident in its correct behavior, even though you didn’t write a test for it.

Practice Time

Imagine the API had another endpoint for the “dish of the day,” which returns a single item. How would you implement reading from that endpoint using Test-Driven Development? Would you add the functionality to MenuFetching or create a new dedicated abstraction? Would NetworkFetchingStub need changes, or would it be ready to support this test too?

You can find the response for this endpoint at https://raw.githubusercontent.com/mokagio/tddinswift_fake_api/trunk/dish_of_the_day.json.

Here’s another exercise for you. Currently, MenuFetcher uses a hardcoded value as the API endpoint. Hardcoded values are useful to get started quickly, but they’re not flexible. You cannot, for example, have one version of the app contacting the staging and one the production backend with a hardcoded URL.

A better approach would be to initialize MenuFetcher with a base URL parameter to make it agnostic of where the API is deployed. Internally, MenuFetcher can construct the endpoint URL by appending the appropriate path to the base value. What tests can you write to implement this refinement?

Hint

Modify the Stub to expect a URLRequest and Result pair and only return the given Result when the URLRequest passed to load(_:) matches the given one.

Key Takeaways

  • Do not make network requests in your unit tests. Hitting the network makes your tests non-deterministic and slower because of its real-world physical constraints.

  • Define an abstraction layer to decouple your source and test code from the lower-level networking implementation. By applying the Dependency Inversion Principle, you’ll shape a more modular software and be able to use Test Doubles.

  • Use as thin a wrapper as possible around URLSession . Because you cannot directly test that code, it should be as minimal and simple as possible.

  • Use a Stub Test Double to simulate successful and failure responses coming from the network. With a Stub, you can provide any kind of input to your networking component.

Endnote

  1. 1.

    If you wanted to go the extra mile and have a security check on the URLSession behavior, you could write an integration test exercising the NetworkFetching method implementation on URLSession. As we’ve seen, interfacing with the real network makes the test non-deterministic and slower. This test should live in a dedicated target that doesn’t run together with the unit tests. You can use it whenever you upgrade to a new version of Xcode as a safety check.

     
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.16.48.181