Lightweight dependency injection and unit testing using async functions
Discover page available: ConcurrencyVery often, making code easy to unit test tends to go hand-in-hand with improving that code’s separation of concerns, its state management, and its overall architecture. In general, the more well-abstracted and organized our code is, the easier it tends to be to test it in an automated fashion.
However, in an effort to make code more testable, we can very often find ourselves introducing a ton of new protocols and other kinds of abstractions, and end up making our code significantly more complicated in the process — especially when testing asynchronous code that relies on some form of networking.
But does it really have to be that way? What if we could actually make our code fully testable in a way that doesn’t require us to introduce any new protocols, mocking types, or complicated abstractions? Let’s explore how we could make use of Swift’s new async/await
capabilities to make that happen.
Injected networking
Let’s say that we’re working on an app that includes the following ProductViewModel
, which uses the very common pattern of getting its URLSession
(that it’ll use to perform network calls) injected through its initializer:
class ProductViewModel {
var title: String { product.name }
var detailText: String { product.description }
var price: Price { product.price(in: localUser.currency) }
...
private var product: Product
private let localUser: User
private let urlSession: URLSession
init(product: Product, localUser: User, urlSession: URLSession = .shared) {
self.product = product
self.localUser = localUser
self.urlSession = urlSession
}
func reload() async throws {
let url = URL.forLoadingProduct(withID: product.id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
product = try decoder.decode(Product.self, from: data)
}
}
Now, there’s really nothing wrong with the above code. It works, and it uses dependency injection to avoid accessing URLSession.shared
directly as a singleton (which already has huge benefits in terms of testing and overall architecture), even though it does use that shared
instance by default, for convenience.
However, it could definitely be argued that inlining raw network calls within types like view models and view controllers is something that ideally should be avoided — as that’d create a better separation of concerns within our project, and would let us reuse that network code whenever we need to perform a similar request elsewhere.
So, to continue iterating on the above example, let’s extract our view model’s product loading code into a dedicated ProductLoader
type instead:
class ProductLoader {
private let urlSession: URLSession
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
If we then make our view model use that new ProductLoader
, rather than interacting with URLSession
directly, then we can simplify its implementation quite significantly — as it can now simply call loadProduct
whenever it’s asked to reload its underlying data model:
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let loader: ProductLoader
init(product: Product, localUser: User, loader: ProductLoader) {
self.product = product
self.localUser = localUser
self.loader = loader
}
func reload() async throws {
product = try await loader.loadProduct(withID: product.id)
}
}
So that’s already quite an improvement. But what if we now wanted to implement a few unit tests to ensure that our view model behaves as we’d expect? To do that, we’re going to need to mock our app’s networking one way or another, as we definitely don’t want to perform any real network calls within our unit tests (as that could add delays, flakiness, and require us to always be online while working on our code base).
Protocol-based mocking
One way to setup that kind of mocking would be to create a protocol-based Networking
abstraction, which essentially just requires us to duplicate the signature of the URLSession.data
method within that protocol, and to then make URLSession
conform to our new protocol through an extension — like this:
protocol Networking {
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse)
}
extension Networking {
// If we want to avoid having to always pass 'delegate: nil'
// at call sites where we're not interested in using a delegate,
// we also have to add the following convenience API (which
// URLSession itself provides when using it directly):
func data(from url: URL) async throws -> (Data, URLResponse) {
try await data(from: url, delegate: nil)
}
}
extension URLSession: Networking {}
With the above in place, we can now make our ProductLoader
accept any object that conforms to our new Networking
protocol, rather than always using a concrete URLSession
instance (we’ll still default to URLSession.shared
for convenience):
class ProductLoader {
private let networking: Networking
init(networking: Networking = URLSession.shared) {
self.networking = networking
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await networking.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
With all of that preparation work done, we can now finally start writing our tests. To do that, we’ll start by creating a mocked implementation of our Networking
protocol, and we’ll then set up a ProductLoader
and ProductViewModel
that uses that mocked implementation when performing all network calls — which in turn enables us to write our tests like this:
class NetworkingMock: Networking {
var result = Result<Data, Error>.success(Data())
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse) {
try (result.get(), URLResponse())
}
}
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var networking: NetworkingMock!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
networking = NetworkingMock()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
loader: ProductLoader(networking: networking)
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
networking.result = try .success(JSONEncoder().encode(product))
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
To learn more about the .stub()
method that’s called above in order to generate stubbed versions of our data models, check out “Defining testing data in Swift”.
Alright! We have now successfully refactored all of our ProductViewModel
-related code to become fully testable, and we’ve started covering it with unit tests. Very nice.
But, if we take a closer look at the above test case, we can see that our ProductLoader
isn’t really participating much in our testing code at all. That’s because we’re really only interested in mocking our actual networking code in this case, since that’s the part of our code that would be problematic to run in a testing context.
Now, it could definitely be argued that we also should’ve added an additional protocol and mocking layer for ProductLoader
as well — which would let us mock it directly, rather than using its real, actual implementation with a mocked networking instance. You could even argue that the above unit test isn’t actually a unit test at all — that it instead is an integration test, as it integrates multiple units (our view model, product loader, and networking) when performing verifications.
However, if we were to take that unit-testing-by-the-book route and introduce yet another protocol and mocking type, then we could quickly end up going down a slippery slope where every single object in our code base also has an associated protocol and mocking type, which would lead to a ton of code duplication and added complexity (even when using code generation tools to automatically generate those types).
But perhaps there’s a way that we could have our cake and eat it too? Let’s see if we could actually make our above test case just verify our ProductViewModel
as a single unit, while also getting rid of those mocks and test-specific protocols in the process.
Adding a bit of functional programming
If we stop thinking about our product loading code in terms of object-oriented constructs like classes and protocols, and instead look at it from a more functional perspective, then we could actually model our view model’s loading code using the following function signature:
typealias Loading<T> = () async throws -> T
That is, a function that loads some form of value in an asynchronous manner, and either returns that value, or throws an error.
Next, let’s change our ProductViewModel
once more, to now accept some function that matches the above signature (specialized using our Product
model), rather than accepting a ProductLoader
instance directly:
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let reloading: Loading<Product>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
}
func reload() async throws {
product = try await reloading()
}
}
One thing that’s very neat about the above pattern is that it still lets us keep using our existing Networking
and ProductLoader
code just like before — all that we have to do is to call that code within the reloading
function/closure that we pass into our ProductViewModel
when creating it:
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
reloading: {
try await loader.loadProduct(withID: product.id)
}
)
}
If you’ve been reading Swift by Sundell for a while, then you might recognize the above pattern from 2019’s “Functional networking in Swift” article, which used Futures and Promises to achieve a similar result.
But here’s where things get really interesting. Now, when unit testing our ProductViewModel
, we no longer have to worry about mocking our networking, or even create a ProductLoader
instance — all that we have to do is to inject an inline closure that returns a specific Product
value, which we can then mutate whenever we want to change our reloading response in any way:
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
reloading: { [unowned self] in self.product }
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
Note how there’s no longer any protocols or mocking types involved in our entire test case! Since we’ve now completely separated our ProductViewModel
from our networking code, we can unit test that class in complete isolation — since, as far as it’s concerned, it just gets access to a closure that loads a Product
value from somewhere.
Scaling things up
But now the big question becomes — how does this pattern scale if we need to perform multiple kinds of loading operations within a given type? To explore that, let’s start by introducing a second kind of async function signature, which’ll let us perform an action using a given value:
typealias AsyncAction<T> = (T) async throws -> Void
Then, let’s say that we wanted to extend our ProductViewModel
with support for marking a given product as a favorite, and to also be able to add that product to a user-defined list. To make that happen, we could inject those two new pieces of functionality as separate closures — like this:
class ProductViewModel {
...
private let reloading: Loading<Product>
private let favoriteToggling: Loading<Product>
private let listAdding: AsyncAction<List.ID>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>,
favoriteToggling: @escaping Loading<Product>,
listAdding: @escaping AsyncAction<List.ID>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
self.favoriteToggling = favoriteToggling
self.listAdding = listAdding
}
func reload() async throws {
product = try await reloading()
}
func toggleProductFavoriteStatus() async throws {
product = try await favoriteToggling()
}
func addProductToList(withID listID: List.ID) async throws {
try await listAdding(listID)
}
}
The above still works fine, but our implementation is arguably starting to become a bit messy, since we now have to juggle multiple closures when initializing our view model.
So let’s take some inspiration from “Extracting view controller actions in Swift”, and group the above three closures into an Actions
struct, which will give us a bit of added structure (no pun intended) when both implementing and initializing our ProductViewModel
:
class ProductViewModel {
...
private let actions: Actions
init(product: Product, localUser: User, actions: Actions) {
self.product = product
self.localUser = localUser
self.actions = actions
}
func reload() async throws {
product = try await actions.reload()
}
func toggleProductFavoriteStatus() async throws {
product = try await actions.toggleFavorite()
}
func addProductToList(withID listID: List.ID) async throws {
try await actions.addToList(listID)
}
}
extension ProductViewModel {
struct Actions {
var reload: Loading<Product>
var toggleFavorite: Loading<Product>
var addToList: AsyncAction<List.ID>
}
}
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
reload: {
try await loader.loadProduct(withID: product.id)
},
toggleFavorite: {
try await loader.toggleFavoriteStatusForProduct(
withID: product.id
)
},
addToList: { listID in
try await listManager.addProduct(
withID: product.id,
toListWithID: listID
)
}
)
)
}
With the above change in place, we can still mock all of the above three actions using simple closures within our tests, while now also making it easy to manage those actions, especially if we keep adding new ones in the future. Of course, the above pattern probably wouldn’t scale that well to types that have 10, 15, 20 actions — but at that point, it’s probably also worth asking if perhaps that type has too many responsibilities to begin with.
However, one fair piece of criticism against the above pattern would be that it does end up pushing some of the internal implementation details of our ProductViewModel
to the call sites that create instances of it. For example, our makeProductViewModel
function now has to know exactly what logic that it’s supposed to place within each of our view model’s Action
closures.
One way to address that problem would be to provide default implementations of those closures using the underlying objects that our production code should ideally use — which could be done using an extension that can be placed within the same file as our ProductViewModel
itself:
extension ProductViewModel.Actions {
init(productID: Product.ID,
loader: ProductLoader,
listManager: ListManager) {
reload = {
try await loader.loadProduct(withID: productID)
}
toggleFavorite = {
try await loader.toggleFavoriteStatusForProduct(
withID: productID
)
}
addToList = {
try await listManager.addProduct(
withID: productID,
toListWithID: $0
)
}
}
}
With that final tweak in place, our makeProductViewModel
can now simply inject our view model’s dependencies, more or less exactly like how things were done when using our earlier, protocol-based setup:
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
productID: product.id,
loader: ProductLoader(networking: networking),
listManager: listManager
)
)
}
With that approach, we’ve arguably struck a quite nice balance between being able to unit test our view model through a very lightweight set of abstractions, while also not leaking any implementation details to whichever call site that’ll initialize that view model within our production code.
Conclusion
While there’s probably no such thing as a perfect dependency injection setup — by experimenting with different techniques, we can often arrive at an architecture that strikes a neat balance between how our code base is organized, its testing needs, and the personal preferences of the developers working on it.
I hope you found this article interesting and useful, and although I’m not saying that anyone should replace all of their protocols with the above kind of functional setup, I think it’s an approach that’s at least worth exploring — especially now that we have the power of async/await
at our disposal.
For more Swift dependency injection techniques, check out this category page, and if you have any questions, comments, or feedback, then feel free to reach out via email.
Thanks for reading!