This is a guest article written by John Sundell, author of Swift by Sundell.
Unit testing can be a fantastic tool for verifying that an app’s various pieces of logic keep working as expected even as they’re being iterated on. However, if a given project only has a very limited amount of tests in place, or if there are no tests at all, then it can often be difficult to know where to start.
So, in this article, we’ll take a look at a few different angles that we can approach this kind of testing from, and how each of those approaches can provide a great starting point for either extending or getting started with building a comprehensive unit testing suite for a Swift app.
Adding coverage from the ground up
While unit tests are, by themselves, certainly no replacement for manual testing, they can sort of act as a “first line of defense” for detecting many common types of regressions and bugs. Like the name implies, unit testing is all about testing individual code units in order to verify that their internal logic keeps working as expected, but clearly separating an entire app into such “units” can sometimes be quite challenging.
However, one place that’s almost universally a great starting point in this regard is to begin by testing an app’s model layer. Since model code tends to be made up of simpler data containers and their associated logic, we can often test that kind of code without having to invent any complex infrastructure, and without having to significantly adjust our code in order for it to become testable.
As an example, let’s say that we’re working on an app for reading various articles, and that the model that we use to represent one of those articles contains a method that lets the user like a given article. Besides incrementing a numberOfLikes count, that method also ensures that each user can only like a given article once. Like this:
struct Article: Identifiable, Codable {
let id: UUID
var title: String
var text: String
private(set) var numberOfLikes = 0
private(set) var likedByCurrentUser = false
mutating func like() {
guard !likedByCurrentUser else { return }
likedByCurrentUser = true
numberOfLikes += 1
}
}
The above numberOfLikes and likedByCurrentUser properties are both marked as private(set) to prevent any outside code from mutating them directly, since we want to enforce the usage of our dedicated like method.
The logic used above is perfect for getting started with unit testing, as we’ll be able to verify it in complete isolation. To do that, we might write something like the following test — which creates an Article value, and then asserts that we’re not able to increment the numberOfLikes property more than once:
class ArticleTests: XCTestCase {
func testUserCanOnlyLikeArticleOnce() {
var article = Article(id: UUID(), title: "Title", text: "Text")
XCTAssertEqual(article.numberOfLikes, 0)
article.like()
XCTAssertEqual(article.numberOfLikes, 1)
article.like()
XCTAssertEqual(article.numberOfLikes, 1, """
A given user should only be able to increment the number of likes once.
""")
}
}
Note how we’re adding a custom error message to the last assert in order to make debugging any failures a bit easier, since it might not be completely obvious at first glance why there’s a second assertion for checking that the numberOfLikes property is equal to 1.
Granted, the above is a really simple test in the grand scheme of things, but that’s also part of the point — if we’re looking to get into a habit of regularly unit testing our code, then we need to start somewhere — and why not start with something simple and then work our way upwards from there?
More testability, fewer assumptions
Once we’ve established a solid test coverage within our core model code, the next step would be to start adding tests to our main app logic (or “business logic”) as well.
For example, let’s say that an app that we’re working on supports multiple themes, and that we’re keeping track of the user’s currently selected theme (and other user-configurable settings) through a SettingsController — which in turn uses Foundation’s UserDefaults API to persist those settings, like this:
class SettingsController {
var theme: AppTheme = .systemDefault {
didSet { saveValues() }
}
...
init() {
loadValues()
}
private func loadValues() {
let defaults = UserDefaults.standard
// Loading the underlying raw value for any saved theme,
// which we then map into AppTheme's initializer:
defaults.string(forKey: "theme")
.flatMap(AppTheme.init)
.map { theme = $0 }
...
}
private func saveValues() {
// Storing each of our settings values by converting them
// into UserDefaults-compatible types, such as String:
let defaults = UserDefaults.standard
defaults.setValue(theme.rawValue, forKey: "theme")
...
}
}
Since we’re no longer dealing with a simple data model, but rather a more complex object that has both dependencies and persisted state, we won’t be able to immediately jump into testing the above code — we first need to make it testable. Making a piece of code testable essentially involves two key things: enabling its dependencies to be controlled and ensuring that we can verify its outcome.
One way to accomplish both of those two things for the above SettingsController would be to inject the underlying UserDefaults instance that it uses for persistence, rather than always using the standard singleton instance. That way we’ll be able to use a custom version of that dependency within our tests, which in turn will give us complete control over it, and enable us to verify that our controller itself is working correctly.
To do that, let’s add a UserDefaults argument to our controller’s initializer, with .standard as its default value. We’ll then store that injected UserDefaults instance in a private property and use that throughout our controller’s implementation (rather than accessing UserDefaults.standard directly):
class SettingsController {
var theme: AppTheme = .systemDefault {
didSet { saveValues() }
}
...
private let storage: UserDefaults
init(storage: UserDefaults = .standard) {
self.storage = storage
loadValues()
}
private func loadValues() {
storage.string(forKey: "theme")
.flatMap(AppTheme.init)
.map { theme = $0 }
...
}
private func saveValues() {
storage.setValue(theme.rawValue, forKey: "theme")
...
}
}
With the above changes in place, our SettingsController now not only has a somewhat clearer implementation, but its testability has also been greatly improved — as we’ll now be able to make it use a custom UserDefaults instance that we can reset when each test starts (which in turn ensures that we’re always starting each test from a blank slate).
We can then verify that our theme persistence logic works as expected by asserting that such a value was written to our custom UserDefaults instance when our controller’s theme property was changed. Like this:
class SettingsControllerTests: XCTestCase {
private var userDefaults: UserDefaults!
private var controller: SettingsController!
override func setUp() {
super.setUp()
let defaultsName = "SettingsControllerTests"
userDefaults = UserDefaults(suiteName: defaultsName)
userDefaults.removePersistentDomain(forName: defaultsName)
controller = SettingsController(storage: userDefaults)
}
func testThemePersistence() {
XCTAssertEqual(controller.theme, .systemDefault, """
Expected the default app theme to follow the system default.
""")
controller.theme = .dark
let defaultsTheme = userDefaults
.string(forKey: "theme")
.flatMap(AppTheme.init)
XCTAssertEqual(controller.theme, defaultsTheme)
}
}
However, while the above test does get the job done, we are currently making quite a few assumptions about the internal logic of our SettingsController within it. For example, we’re assuming that its theme property will always be persisted using the current theme’s rawValue, and by using the key "theme".
That might not seem like a big deal, but assumptions like that tend to make tests harder to maintain over time, since a completely legitimate code change might cause those assumptions to break, and our tests to start failing for no good reason.
Now, in this case, we could solve this issue by exposing the keys that our controller uses for persistence as part of its public API, and then have our test simply use those keys, but that’d lead us down another tricky path — since those implementation details should ideally be kept private within our SettingsController itself.
Instead, let’s take a moment to think about what we really want our above test to actually verify. At the end of the day, how our controller’s state gets persisted isn’t really that important, we just want to ensure that it does get persisted somehow.
With that in mind, let’s modify our test to instead simply create a second instance of our SettingsController type, and to then assert that the first controller’s modified state was indeed persisted and loaded by the second one. That way we’re sort of simulating the process of our app relaunching, which not only gives us a more robust and simpler test, but also one that executes under more realistic conditions:
class SettingsControllerTests: XCTestCase {
...
func testThemePersistence() {
XCTAssertEqual(controller.theme, .systemDefault, """
Expected the default app theme to follow the system default.
""")
controller.theme = .dark
let secondController = SettingsController(storage: userDefaults)
XCTAssertEqual(secondController.theme, .dark, """
Theme setting was not successfully persisted.
""")
}
}
Verifying error handling code
When manually testing an app (or by simply using it ourselves) we’re likely to discover most major bugs or issues that’ll occur within the most commonly hit code paths. However, we’re also very likely to have a fair amount of code that handles other, less common conditions as well — such as various kinds of error handling logic.
As an example, let’s say that our article reading app from before also supports deep links — custom URLs that link to a specific destination within our app. To resolve a destination from such a URL value, we might write something like the following static method:
extension DeepLinkDestination {
static func resolve(from link: URL) throws -> Self {
guard link.scheme == "myapp" else {
throw DeepLinkError.invalidScheme(link.scheme)
}
switch link.host {
case "article":
let rawID = link.pathComponents[1]
guard let id = Article.ID(uuidString: rawID) else {
throw DeepLinkError.invalidArticleID(rawID)
}
return .article(id: id)
case "search":
let query = link.pathComponents[1]
return .search(query: query)
default:
throw DeepLinkError.invalidHost(link.host)
}
}
}
Just within that short code sample, there’s a number of different branches and conditions that our code needs to successfully handle. Making sure that we’re always covering each of those cases through manual testing will be quite difficult (and incredibly repetitive), so let’s instead implement a series of unit tests for it.
When testing the above kind of data transformation code, it’s typically a good idea to cover three kinds of input: valid (commonly referred to as the “happy path”), invalid, and missing. Let’s start by implementing tests for the first two of those three variants, which could be done like this for our article links specifically:
class DeepLinkDestinationTests: XCTestCase {
func testArticleLinkWithValidID() throws {
let id = Article.ID()
let link = URL(string: "myapp://article/\(id)")!
let destination = try DeepLinkDestination.resolve(from: link)
XCTAssertEqual(destination, .article(id: id))
}
func testArticleLinkWithInvalidID() {
let link = URL(string: "myapp://article/not-an-id")!
XCTAssertThrowsError(try DeepLinkDestination.resolve(from: link)) {
let error = $0 as? DeepLinkError
XCTAssertEqual(error, DeepLinkError.invalidArticleID("not-an-id"))
}
}
}
That’s a great start, but now the question is, how will we go about verifying how our code handles missing article IDs? If we again take a look at our resolve method, it turns out that we’re not actually handling that case very gracefully, as we’re currently assuming that link.pathComponents[1] will always be a valid expression — which it won’t be in the case of a missing ID (and thus causing a crash).
To fix that issue, let’s insert another guard statement before we subscript into our URL’s pathComponents array, and if that array doesn’t contain enough elements, we’ll now throw a proper error instead of having our app crash:
extension DeepLinkDestination {
static func resolve(from link: URL) throws -> Self {
...
switch link.host {
case "article":
guard link.pathComponents.count > 1 else {
throw DeepLinkError.missingArticleID(link)
}
let rawID = link.pathComponents[1]
guard let id = Article.ID(uuidString: rawID) else {
throw DeepLinkError.invalidArticleID(rawID)
}
return .article(id: id)
...
}
}
}
With the above change in place, we’ll now be able to easily test the case of a missing ID, just like how we previously tested how our code handles invalid data — by verifying that the correct error is thrown:
class DeepLinkDestinationTests: XCTestCase {
...
func testArticleLinkWithMissingID() throws {
let link = URL(string: "myapp://article/")!
XCTAssertThrowsError(try DeepLinkDestination.resolve(from: link)) {
let error = $0 as? DeepLinkError
XCTAssertEqual(error, DeepLinkError.missingArticleID(link))
}
}
}
Just like how we ended up making our SettingsController implementation more flexible and easier to read as part of making it testable, the above example shows that when we start going through our logic in order to add tests for it, we can also often spot mistakes and errors that previously would have eventually caused bugs in production.
Running a test suite on Bitrise
Once we’ve started improving an app’s overall test coverage, the final part is to make sure that those tests actually get run on a regular basis, and a great way to do just that is to make Bitrise run them automatically on each change that we make to our project.
The good news is that this is most often incredibly easy to do. For an iOS app, all that we have to do is to add a “Xcode Test for iOS” Step to our project’s Workflow, and Bitrise will detect our unit testing bundle within our Xcode project and will then run all of our tests without any extra work on our part.
If we’re looking to test a Swift Package Manager-powered library, on the other hand, we do have to implement a short little script that tells Bitrise to execute swift test. Like this:
#!/usr/bin/env bash
# fail if any commands fails
set -e
# debug log
set -x
swift test
We’ll then place the above script within a Do anything with script Step within our Workflow, and our tests will automatically run on every new commit.
Conclusion
If you haven’t yet gotten into the habit of regularly testing your Swift code, or if you’re looking to improve the test coverage of one of your projects, then I hope that this article has given you a few ideas and tips on how to do just that. For much more information on the topic of unit testing, check out the category page for that topic over on Swift by Sundell, and feel free to reach out to me via Twitter if you have any questions, comments, or feedback.
Thanks for reading! 🚀
Join the State of Swift 2020 webinar on-demand
Tune into our webinar on-demand to learn about the State of Swift in 2020, and where it's likely to go next. Join Kaya Thomas, Daniel Steinberg, Vincent Pradeilles, and our host, Alex Logan, and learn about recent events, best practices, and the challenges of being a Swift developer. Watch it on-demand!