This article is also available in Chinese.
When firing network requests, there are often many side effects that need to take place. Side effects are poison for testability, however, and may vary from app to app and request to request. If we can create a system where we can create and compose these side effects together, we can increase the testability and other factors.
Imagine a very simple network client:
final class NetworkClient {
let session: URLSession
init(session: URLSession = URLSession.shared) {
self.session = session
}
func send<Output: JSONInitializable>(request: Request<Output>) -> Promise<Output> {
let urlRequest = RequestBuilder(request: request).urlRequest
return session.data(with: urlRequest)
.then({ data, response in
let json = try JSONSerialization.jsonObject(with: data)
return Output(json: json)
})
}
}
This class wraps a URLSession
. It has a send
method that accepts Request
objects that have some associated phantom type Output
that conforms to JSONInitializable
. The send function returns a promise with the same type as Output
. In the body of the send
method, a URLRequest
is built using the RequestBuilder
type, and that request is sent via the URL session. The data that is returned from the network request is parsed as JSON, and hydrates a model, which is the value inside the returned promise.
This network client is greatly simplified (namely, I left out some optional handling stuff), but is basically the code I want to work with.
This class right now is great. It’s a simple object that doesn’t touch anything global (minus the network, gated through the URLSession
), and is easily testable. We could wrap the URLSession
in a protocol, inject it in the NetworkClient
initializer, and mock it in our tests, and test the logic around constructing an object from some JSON response. Testable network code!
The trouble here is that while it does do plenty of good stuff, there’s still lots that it doesn’t do. For example, we might want it to:
- register a background task with the application, so that the network request can keep working after the user presses the home button
- show and hide the application’s network activity indicator
- add a specific header for authorization with some API
We could add all of these things to our NetworkClient
class, but each of them touches some piece of global state or singleton. The first one touches the UIApplication
singleton, the second, a shared counter, and the third, some form of storage that keeps track of the auth token.
If we added all of these globals and special cases, it’ll be a lot harder to test this object. We’ll have to mock up and inject each of these globals in separately, and our tests will get that much more complicated.
These three behaviors are expected to fire on every network request, but other requests will have request-specific behaviors that would ideally be reusable. For example, many (but not all) requests will need error handling, to let the user know that some action they took didn’t go through successfully. Other requests will need to save things to Core Data, and I’d ideally like the network client to know nothing about Core Data. While I won’t be focusing on examples of request-specific behaviors here, I’ll show the code for how to integrate them.
I want a way to decouple these behaviors implementation details from the network client, so that we can test the client separately from each of the behaviors.
Let’s define a request behavior with a protocol:
protocol RequestBehavior {
var additionalHeaders: [String: String] { get }
func beforeSend()
func afterSuccess(result: Any)
func afterFailure(error: Error)
}
The protocol provides a pretty boring default implementation for each method:
extension RequestBehavior {
var additionalHeaders: [String: String] {
return [:]
}
func beforeSend() {
}
func afterSuccess(result: Any) {
}
func afterFailure(error: Error) {
}
}
The basic idea is each behavior gets callbacks when specific network events occur, and they can execute code. Two things will be useful for us as we develop this: an “empty” request behavior that doesn’t do anything, and a request behavior that combines many request behaviors. The “empty” behavior inherits all the implementations from the protocol extension, and the “combined” behavior stores an array of behaviors and calls the relevant method on each behavior:
struct EmptyRequestBehavior: RequestBehavior { }
struct CombinedRequestBehavior: RequestBehavior {
let behaviors: [RequestBehavior]
var additionalHeaders: [String : String] {
return behaviors.reduce([String: String](), { sum, behavior in
return sum.merged(with: behavior.additionalHeaders)
})
}
func beforeSend() {
behaviors.forEach({ $0.beforeSend() })
}
func afterSuccess(result: Any) {
behaviors.forEach({ $0.afterSuccess(result: result) })
}
func afterFailure(error: Error) {
behaviors.forEach({ $0.afterFailure(error: error) })
}
}
These are very abstract for the moment but will become useful soon.
Next, we need to modify our network client to call the methods in our request behavior at the right time.
final class NetworkClient {
let session: URLSession
let defaultRequestBehavior: RequestBehavior
init(session: URLSession = URLSession.shared, defaultRequestBehavior: RequestBehavior = EmptyRequestBehavior()) {
self.session = session
self.defaultRequestBehavior = defaultRequestBehavior
}
func send<Output: JSONInitializable>(request: Request<Output>, behavior: RequestBehavior = EmptyRequestBehavior()) -> Promise<Output> {
let combinedBehavior = CombinedRequestBehavior(behaviors: [behavior, defaultRequestBehavior])
let urlRequest = RequestBuilder(request: request, behavior: combinedBehavior).urlRequest
combinedBehavior.beforeSend()
return session.data(with: urlRequest)
.then({ data, response in
let json = try JSONSerialization.jsonObject(with: data)
let result = try Output(json: json)
combinedBehavior.afterSuccess(result: result)
return result
})
.catch({ error in
combinedBehavior.afterFailure(error: error)
})
}
}
The defaultRequestBehavior
from the client and the behavior
for the specific request both default to a new EmptyRequestBehavior
, so that they become effectively opt-in. Our network client combines the behavior for the individual request and the behavior for the whole client. It passes that to the RequestBuilder
so it can use the additionalHeaders
, and then it calls beforeSend
, afterSuccess
, and afterFailure
at the appropriate times and with the appropriate values.
With this simple separation of request and side effect, it’s now possible to test the client separately from each the behaviors we might want to add to it. These behaviors themselves, since they’re their own objects, can be easily instantiated and tested.
Let’s take a look at at the behaviors I mentioned above. First, registering a background task for each network request:
final class BackgroundTaskBehavior: RequestBehavior {
private let application = UIApplication.shared
private var identifier: UIBackgroundTaskIdentifier?
func beforeSend() {
identifier = application.beginBackgroundTask(expirationHandler: {
self.endBackgroundTask()
})
}
func afterSuccess(response: AnyResponse) {
endBackgroundTask()
}
func afterFailure(error: Error) {
endBackgroundTask()
}
private func endBackgroundTask() {
if let identifier = identifier {
application.endBackgroundTask(identifier)
self.identifier = nil
}
}
}
While these background tasks are often only registered at the time the app is closed, and usually a single one is registered, you can register as many as you want, at any time. Because this behavior requires the maintenance of state, it’s especially suited to being its own object, and thus a prime candidate for a request behavior. Testing this behavior involves wrapping the UIApplication
in a protocol, injecting it in an initializer, and confirming with a mock that the right methods were called at the right times.
Next, let’s look at the network activity indicator.
class ActivityIndicatorState {
static let shared = ActivityIndicatorState()
let application = UIApplication.shared
var counter = 0 {
didSet {
application.isNetworkActivityIndicatorVisible = counter != 0
}
}
}
class NetworkActivityIndicatorBehavior: RequestBehavior {
let state = ActivityIndicatorState.shared
func beforeSend() {
state.counter += 1
}
func afterFailure(error: Error) {
state.counter -= 1
}
func afterSuccess(response: AnyResponse) {
state.counter -= 1
}
}
This behavior is another one that’s typically impossible to test. Now that it’s broken up into two objects, instead of being embedded in the NetworkClient
, it’s now possible. Inject the application singleton (wrapped in a protocol, passed into the initializer) into ActivityIndicatorState
, and you can test that it correctly turns the network activity indicator on and off by changing the value of the counter. Inject a ActivityIndicatorState
(also wrapped in a protocol, passed into the initializer), and you can test the incrementing and decrementing of counter
property as well.
Finally, let’s look at how we might bring the global state of an auth token into a request behavior.
struct AuthTokenHeaderBehavior: RequestBehavior {
let userDefaults = UserDefaults.standard
var additionalHeaders: [String : String] {
if let token = userDefaults.string(forKey: "authToken") {
return ["X-Auth-Token": token]
}
return [:]
}
}
This one is simple enough that I might not test it, but nevertheless, something that was really hard before is a lot simpler now. Inject the standard UserDefaults
object (surprise, wrapped in a protocol again), and test that it correctly returns the header dictionary. One more test for if the UserDefaults
doesn’t have the key in question, and you’re all done.
To access the network client in an iOS application, it’s typically done with a global accessor. Because this client requires many lines of initialization, I might put it in an immediately-executed closure:
enum SharedNetworkClient {
static let main: NetworkClient = {
let behavior = CombinationRequestBehavior(behaviors: [
AuthTokenHeaderBehavior(),
NetworkActivityBehavior(),
BackgroundTaskBehavior(),
])
return NetworkClient(behavior: behavior)
}()
}
I’ve also started putting singletons in their own namespace (called SharedX
), instead of in the type itself, to help remind me that singletons should just be objects, and that they aren’t exempt from being testable objects and good citizens.
A few final notes: the network library Moya has a feature similar to this called “plugins”. While my implementation is a little bit different, it’s a very similar idea. Moya’s plugins only operate on a per-client basis, adding behaviors that operate per-request is also very useful for performing side-effects, like saving to some form of persistence or cache, or presenting an error.
You may find that you need other methods on your request behavior, which you should feel free to add. One that I omitted here for brevity is:
func modify(request: URLRequest) -> URLRequest
This function lets you mutate the URLRequest
in any way that you want, even returning a totally different URLRequest, if you want. This is obviously a powerful and dangerous action, but for some requirements it is useful and even necessary.
Network code is typically hard to maintain, because it touches an inherently global resource, which lends itself to tough-to-test singleton designs. Request behaviors are a useful way of separating the code that sends requests from any side-effects that need to happen during that request. This little abstraction simplifies network code, adds reusability for per-request behaviors, and vastly increases testability.
Once you feel comfortable with these topics, read Advanced Requests Behaviors as well.