Chapter 10 – Testing Network Code – Exercise Solution
Part 3: How to keep code tidy when implementing multiple API endpoint
The Test-Driven Development in Swift chapter on using TDD with networking code closes with two exercise suggestions.
In the previous two bonus content posts, we saw how to make the base URL used by MenuFetcher
, the component initiating network requests, configurable at init
time by writing a test with a Stub or with a Spy.
The other exercise asks to add data fetching from the dish of the day API endpoint, which returns a single JSON object representing a dish for the menu ordering app we built throughout the book to render.
I suggest adding a new endpoint to solidify the learnings from the chapter because the steps to take are the same as shown in the chapter.
A walkthrough of that process would be nothing more than a repetition of what’s already in the book.
Instead, I want to make this issue different by discussing a few trade-offs and geeking out on a DRY networking implementation.
We’ll discuss how to approach the abstraction layer that represents the capability to fetch the dish of the day, how to structure its concrete implementation, then play around with Swift generics to remove boilerplate code.
Multiple abstractions or one to rule them all?
The book teaches TDD by building an approximation of a real-world application for a fictional Italian restaurant owner called Alberto.
Alberto’s menu ordering app fetches the menu from an API, renders it on screen, and lets users order dishes.
The codebase defines an abstraction for the component in charge to fetch the menu:
// MenuFetching.swift
import Combine
protocol MenuFetching {
func fetchMenu() -> AnyPublisher<[MenuItem], Error>
}
The abstraction is valuable in the production code because it insulates architectural layers from implementation details.
The tests can use MenuFetching
to build Test Doubles to control different behavioral facets and cover a broader set of scenarios.
When it comes time to add a new endpoint with data to fetch, we have two options: add a new method to the existing protocol
or create a new dedicated abstraction.
If we were to add a new method to MenuFetching
it would be appropriate to rename it to reflect it’s now representing more than a single resource fetch.
protocol ResourceFetching {
func fetchMenu() -> AnyPublisher<[MenuItem], Error>
func fetchDishOfTheDay() -> AnyPublisher<MenuItem, Error>
}
Alternatively, we could leave MenuFetching
as it is and add a new dedicated abstraction:
protocol MenuItemFetching {
func fetchDishOfTheDay() -> AnyPublisher<MenuItem, Error>
}
I decided to name it MenuItemFetching
to represent the type of resource it procures.DishOfTheDayFetching
is another possible name we could have used.
I prefer the dedicated abstraction approach to the general one.
A sharp, narrow-focused abstraction allows us to write consuming code that is straightforward to reason about.
It also declares intent more clearly.
With the abstraction locked in, it’s time to move to the concrete implementation.
A dedicated implementation or a new method on an existing one?
Coding a concrete implementation for DishOfTheDayFetching
presents the same design dilemma as choosing how to define the protocol
.
Do we write a dedicated DishOfTheDayFetcher
implementation or make our existing networking component, MenuFetcher
, conform to DishOfTheDayFetching
as well as MenuFetching
?
Building a dedicated object to implement DishOfTheDayFetching
would keep the code homogeneous.
We’d have MenuFetcher
for MenuItemFetching
and DishOfTheDayFetching
for DishOfTheDayFetcher
.
Symmetric, straightforward, and satisfying.
But also shortsighted.
Spawning a dedicated object for each protocol
in the codebase is a lost opportunity to leverage a sort of economy of scale in our codebase componentry.
If we give a single object the responsibility for multiple resource fetches, we can optimize its implementation for better code reuse and extensibility.
Let’s not try to be too smart, though.
We shall write a straightforward implementation first, and only once that works, attempt to optimize it.
Partition problem and solve sequentially.
Don’t try to do two things at once.
I’ll jump through the TDD process and only show the finished production code here.
// APIClient.swift
class APIClient: DishOfTheDayFetching, 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()
}
func fetchDishOfTheDay() -> AnyPublisher<MenuItem, Error> {
let request = URLRequest(url: baseURL.appendingPathComponent("dish_of_the_day.json"))
return networkFetching.load(request)
.decode(type: MenuItem.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
The dish of the day fetching tests look the same as the ones for the menu list.
If you don’t have the book at hand, you can see the menu list fetching tests here.
Even though we now have a single object implementing multiple requests, as long as we follow the Dependency Inversion Principle and declare our components as interacting through abstractions, the rest of the application won’t know about it.
For example, MenuList.ViewModel
expects a MenuFetching
-conforming type as an init
parameter.
When we pass it an APIClient
instance, MenuList.ViewModel
will only have access to the methods APIClient
implements from MenuFetching
, not to its entire interface.
This allows us to keep all the benefits of local reasoning and encapsulation while also unlocking code optimizations in the APIClient
implementation.
Refactor: DRY
Looking at the APIClient
implementation from a distance, we can see both methods follow the same pattern:
let request = URLRequest(url: baseURL.appendingPathComponent(<# path #>))
return networkFetching.load(request)
.decode(type: <# resource type #>.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
Let’s apply “Don’t Repeat Yourself” and extract this duplication in a dedicated method:
// APIClient.swift
// ...
func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
return fetch(fromPath: "menu_response.json")
}
func fetchDishOfTheDay() -> AnyPublisher<MenuItem, Error> {
return fetch(fromPath: "dish_of_the_day.json")
}
private func fetch<Resource>(
fromPath path: String
) -> AnyPublisher<Resource, Error> where Resource: Decodable {
let request = URLRequest(url: baseURL.appendingPathComponent(path))
return networkFetching.load(request)
.decode(type: Resource.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
I’d like to take a moment for us to appreciate how neat generics are.
Thanks to them, we can use one implementation to power two functions with different return types.
Thanks to the extensive unit tests coverage we developed by writing our code test-first, the only thing we need to do to verify this refactor is running the tests.
Without tests, we’d have to manually trigger both code paths by using the app, which is more time-consuming.
Refactor: Separate configuration from execution logic
Let’s keep geeking out with the code.
APIClient
is currently responsible for executing requests and holding the knowledge of the endpoints’ path and the type of resource they return.
We can extract this knowledge in a dedicated object to make it easier to consult and update:
// Endpoint.swift
import Foundation
struct Endpoint<Resource: Decodable> {
let path: String
let resourceType = T.self
func urlRequest(with baseURL: URL) -> URLRequest {
URLRequest(url: baseURL.appendingPathComponent(path))
}
}
Endpoint
is a value type that we can use to hold the information about the path of an API endpoint and the type of domain object to map its response to.
We can then build a list of endpoints for our API:
// Endpoints.swift
enum Endpoints {
static let menu = Endpoint<[MenuItem]>(path: "menu_response.json")
static let dishOfTheDay = Endpoint<MenuItem>(path: "dish_of_the_day.json")
}
Finally, we can update APIClient
to use the new Endpoint
type:
// APIClient.swift
// ...
func fetchMenu() -> AnyPublisher<[MenuItem], Error> {
return fetch(from: Endpoints.menu)
}
func fetchDishOfTheDay() -> AnyPublisher<MenuItem, Error> {
return fetch(from: Endpoints.dishOfTheDay)
}
private func fetch<Resource>(
from endpoint: Endpoint<Resource>
) -> AnyPublisher<Resource, Error> where Resource: Decodable {
return networkFetching.load(endpoint.urlRequest(with: baseURL))
.decode(type: endpoint.resourceType, decoder: decoder)
.eraseToAnyPublisher()
}
Once again, we can get fast feedback on this refactor by running the tests.
Admittedly, for a class the size of APIClient
in a codebase the size of the app from the book, this refactoring was more of a yack shave.
But in a bigger codebase, with more API endpoints to interact with, there’s a real benefit in separating the endpoint configuration logic from the code that performs the request itself.
When the configuration code is isolated, it becomes easier to find, reason about, and update.
Networking is a core and multifaceted part of most software, so it’s not surprising that it’s taken us three bonus content episodes to discuss two relatively simple exercises.
We only worked with straightforward GET requests here, but there’s much more to networking than what we’ve covered, like sending data to the backend with POST requests or multipart form uploads.
That’s all to say, networking is a complicated part of application development for which testing is crucial, both to guide the design and ensure the behavior.
I hope the book and this series of posts have given you good food for thought on how to approach the networking needs of your own software projects.
As always, I’m keen to hear what you think.
Get in touch on Twitter @mokagio or email me at hello@tddinswift.com.