Pragmatic unit testing in Swift
Discover page available: Unit TestingIt’s sometimes easy to think of the decision of whether or not to use unit testing as a binary one: either testing is fully embraced, and all of our project’s code refactored to be completely testable — or no testing will be done at all.
When faced with such a binary decision, it’s not very surprising that many teams opt for the latter of the two options — often because of time constraints, tight deadlines, and important new features that need shipping.
However, there’s really no reason to treat unit testing as such a big, binary decision. After all, any form of automated testing is just another tool that we can use to improve our code and the way we work with it. So this week, let’s take a look at a few different ways to deploy unit testing in a more pragmatic manner — to use testing to solve immediate problems, and to enable our code to be tested without having to fundamentally change it.
Verifying bug fixes
When starting to add unit tests to a project — or when wanting to extend the coverage of an existing, partial test suite — it can often be hard to decide where to start. Do we start by verifying the functionality that’s closest to the user and our app’s UI, and then work our way down the stack, or vice versa?
Rather than having to search for the perfect starting point, a great way to get into the habit of unit testing changes to a code base is to start by using tests to verify bug fixes. Not only does that let us verify that we actually fixed the bug we were aiming to fix, it also lets us slowly but surely build up a solid suite of tests — all while improving the quality of our code at the same time.
As an example, let’s say that we’re working on an app that lets our users read various forms of written content. Historically, our app has only supported books and magazines, but we’ve recently also added support for newspapers — with those three types of content being represented using this enum:
extension Item {
enum Kind {
case book
case magazine
case newspaper
}
}
Now let’s say that when shipping the newspaper feature, we started getting reports from users that no newspapers ever show up within the app’s “Recommended” section — which turns out to be a bug caused by the following code:
struct RecommendedItems {
var books = [Item]()
var magazines = [Item]()
var newspapers = [Item]()
func items(ofKind kind: Item.Kind) -> [Item] {
if kind == .book {
return books
} else {
return magazines
}
}
}
The problem is that we’re making the assumption that if the requested Item.Kind
isn’t .book
, then that must mean that we should return magazines — which might’ve made total sense when that code was written (since at that point, books and magazines were the only two kinds of items that we supported).
While we could of course simply fix the above bug and move on, let’s use it as an opportunity to add a test that’ll ensure that things will keep working in the future. So before we implement our fix, let’s add a test for it — like this:
class RecommendedItemsTests: XCTestCase {
func testRecommendedNewspapers() {
let item = Item(
kind: .newspaper,
id: "nytimes",
name: "The New York Times"
)
let recommended = RecommendedItems(newspapers: [item])
XCTAssertEqual(
recommended.items(ofKind: .newspaper),
[item]
)
}
}
The above test will currently fail, which is great, as it reproduces the problem that our users have been facing. With that failing test in place, let’s now actually fix the bug, by turning our original if/else
statements into a switch
in order to handle all cases (which’ll also give us a compile time error if we ever end up with an unhandled case again):
struct RecommendedItems {
var books = [Item]()
var magazines = [Item]()
var newspapers = [Item]()
func items(ofKind kind: Item.Kind) -> [Item] {
switch kind {
case .book: return books
case .magazine: return magazines
case .newspaper: return newspapers
}
}
}
With the above fix in place, our test will now pass — and we’re ready to submit our patch! Not only does the above approach give us a greater degree of confidence when fixing bugs, it also lets us make sure that the same bug won’t happen twice, since if we ever end up causing the same regression again — our test will now tell us about it.
Safeguarding against future errors
While the above kind of bug-fixing tests are incredibly useful, they’re written in reaction to a bug that was already caused, and only protects us against the exact same regression. So let’s see if we can take things one step further, while we’re already working on this part of the code base, to give ourselves a much stronger safeguard against related regressions as well.
To do that in this case, let’s add a test that’ll make sure that our RecommendedItems
type will always be capable of returning items for any Item.Kind
. We’ll start by making our Item.Kind
enum conform to CaseIterable
— which will let us iterate over its cases:
extension Item.Kind: CaseIterable {}
For more information on CaseIterable
, check out “Enum iterations in Swift”.
Next, let’s make it slightly easier to create stubbed Item
values (instances that we create purely for testing reasons), by adding the following extension to our unit testing target:
extension Item {
static func stub(ofKind kind: Kind, id: Item.ID) -> Item {
return Item(kind: kind, id: id, name: "\(kind)-\(id)")
}
}
For much more advanced ways of stubbing values, check out “Defining testing data in Swift”.
With those two small extensions in place, we’re now ready to write our test, which will iterate over all cases within Item.Kind
in order to make sure that there’s a recommended item defined for each kind — like this:
class RecommendedItems: XCTestCase {
...
func testItemsForAllKinds() {
let book = Item.stub(ofKind: .book, id: "book")
let magazine = Item.stub(ofKind: .magazine, id: "magazine")
let newspaper = Item.stub(ofKind: .newspaper, id: "newspaper")
let recommended = RecommendedItems(
books: [book],
magazines: [magazine],
newspapers: [newspaper]
)
for kind in Item.Kind.allCases {
let items = recommended.items(ofKind: kind)
switch kind {
case .book:
XCTAssertEqual(items, [book])
case .magazine:
XCTAssertEqual(items, [magazine])
case .newspaper:
XCTAssertEqual(items, [newspaper])
}
}
}
}
The above test might be simple, but it gives us a much stronger guarantee that the way we keep track of recommended items within our app will keep working as we iterate on our code — even if we might yet again add support for a new content type in the future.
Pragmatic refactoring
While writing tests for the above kind of synchronous model code is often quite straightforward, things tend to get increasingly difficult and tricky as we enter the domain of asynchronous code — especially if the code in question wasn’t written with testing in mind.
For example, let’s say that we wanted to write a test to verify that a boolean flag is correctly stored in our app’s UserDefaults
when the user successfully completes our onboarding process. All of that code currently lives inside of a OnboardingViewController
, and looks like this:
class OnboardingViewController: UIViewController {
...
func finishOnboarding() {
let task = URLSession.shared.dataTask(
with: .onboardingFinishedEndpoint
) { [weak self] _, _, error in
DispatchQueue.main.async {
guard let self = self else { return }
if let error = error {
ErrorPresenter.shared.present(error, in: self)
return
}
UserDefaults.standard.setValue(true,
forKey: UserDefaultsKeys.onboardingFinished
)
self.dismiss(animated: true)
}
}
task.resume()
}
}
As it currently stands, writing tests for the above code will be quite difficult. The main problem is that singletons are used to access URLSession
, ErrorPresenter
and UserDefaults
, which will make it hard (or even impossible) for us to take control over those instances within our tests, in order to verify our functionality in a predictable way. Ideally we’d like those dependencies to be injected, so that we’d be able to mock them.
However, completely refactoring OnboardingViewController
to fully support dependency injection (and to also abstract our dependencies to the extent that they can be fully mocked) will most likely be a huge task — so let’s see if we can find a way to enable us to test our functionality with only a minor set of tweaks.
One way to do so is to encapsulate our view controller’s dependencies using functions, and then define a struct that contains all of those functions — like this:
struct OnboardingDependencies {
// Our networking code, modeled as a function that takes
// a URL and a completion handler, and then calls the
// underlying URLSession:
var networking: (URL, @escaping (Error?) -> Void) -> Void = {
url, handler in
let task = URLSession.shared.dataTask(with: url) {
_, _, error in
DispatchQueue.main.async {
handler(error)
}
}
task.resume()
}
// Our error presenting function can be directly referenced,
// since Swift supports first class functions:
var errorPresenting = ErrorPresenter.shared.present
// Our key/value persistence code, which we turn into
// a function that wraps our app's standard UserDefaults:
var keyValuePesistance: (String, Bool) -> Void = {
UserDefaults.standard.setValue($1, forKey: $0)
}
}
With the above in place, we can now define a new dependencies
property within our view controller, and simply call the above functions in order to access our dependencies’ functionality:
class OnboardingViewController: UIViewController {
var dependencies = OnboardingDependencies()
...
func finishOnboarding() {
dependencies.networking(.onboardingFinishedEndpoint) {
[weak self] error in
guard let self = self else { return }
if let error = error {
self.dependencies.errorPresenting(error, self)
return
}
self.dependencies.keyValuePesistance(
UserDefaultsKeys.onboardingFinished,
true
)
self.dismiss(animated: true)
}
}
}
The above might not be our ideal way of doing dependency injection, but it works remarkably well, and doesn’t require any modifications to our view controller’s API — nor to any of its dependencies. We don’t need to define any new protocols, or fundamentally change our code, but we’ve still enabled the above function to be fully tested.
To do just that, we’ll simply create an instance of OnboardingViewController
, and then override the dependency functions that we wish to take control over. Here’s how we might do that to write the test that we originally set out to add — which verifies that the correct flag is persisted when the user finishes the onboarding process:
class OnboardingViewControllerTests: XCTestCase {
func testPersistingOnboardingFinished() {
let vc = OnboardingViewController()
var persistance: (key: String, value: Bool)?
// Hard-wire our networking function to always
// return a nil error when called:
vc.dependencies.networking = { _, handler in
handler(nil)
}
// Override our view controller's key/value persistance
// function in order to capture its input:
vc.dependencies.keyValuePesistance = {
persistance = ($0, $1)
}
vc.finishOnboarding()
// Verify that the correct key and value were persisted:
XCTAssertEqual(persistance?.key, UserDefaultsKeys.onboardingFinished)
XCTAssertEqual(persistance?.value, true)
}
}
Pretty cool! While our primary goal might’ve been to make OnboardingViewController
testable, as an added bonus, we’ve now also made that class both simpler and more flexible — since it no longer has any strong coupling to its dependencies.
Conclusion
While unit testing might initially seem like a very big deal — something that will require months of refactoring work before a single test can be written — that’s rarely the case. While we might need to perform some modifications to certain parts of our code base in order to test it, those changes can often be done in a fully backward compatible manner, and in a way that takes just a short amount of time to implement.
At the end of the day, unit testing is not a way of life, nor will it completely remove the need for manual testing, or magically make an app error-free — it’s just a tool like any other. A tool that — if tactically and thoughtfully deployed — can help us verify bug fixes, prevent many kinds of future errors, and ensure that our code keeps working as intended as we add new features and capabilities.
What do you think? Do you already use some of the techniques within this article, or will you try them out? Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀