Unit Testing UIKit - Views
Unit testing is a way to automate verification of code. It’s not the be-all end-all of program verification, but it’s a really good start. Unit testing goes a long way towards augmenting QA and manual verification to ensure that your code works.
I’ve been practicing test driven development for more than 5 years, and have been building iOS apps since iOS 4. I wanted to document the techniques I’ve learned and come up with for testing UIKit code.
The standard intro-to-testing examples are things like “verify that mathematical operations work as they should” and “convert arabic number to roman numeral” katas. I won’t do that. I’m going to assume you know the basics of unit testing, and I will be going in depth to using UIKit from a unit testing context.
Implementation
Let’s start with the following swift code, which is a view controller for a list of UISwitch
s. The responsibility for this view controller is to allow the user to view and change settings on the device.
For the sake of inlining as much as possible, I’m going to programmatically lay out this view controller.
import UIKit
struct Setting: Hashable {
let name: String
var isEnabled: Bool
}
protocol SettingsManager {
func settings() -> [Setting]
func set(isEnabled: Bool, for setting: Setting)
}
class ToggleTableViewCell: UITableViewCell {
let toggle = UISwitch()
var onToggle: ((Bool) -> Void)?
override func prepareForReuse() {
onToggle = nil
super.prepareForReuse()
}
@objc private func didToggleSwitch() {
onToggle?(toggle.isOn)
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: nil)
toggle.addTarget(self, action: #selector(self.didToggleSwitch), for: .valueChanged)
contentView.addSubview(toggle)
toggle.translatesAutoresizingMaskIntoConstraints = false
toggle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16).isActive = true
toggle.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4).isActive = true
toggle.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4).isActive = true
// It’s important that it look pretty.
let spaceConstraint = toggle.leadingAnchor.constraint(equalTo: textLabel!.trailingAnchor, constant: 8)
spaceConstraint.priority = .defaultHigh
spaceConstraint.isActive = true
}
required init?(coder: NSCoder) {
fatalError("no")
}
}
class SettingsViewController: UIViewController {
let settingsManager: SettingsManager
let tableView = UITableView()
lazy var dataSource: UITableViewDiffableDataSource<Int, Setting> = {
return UITableViewDiffableDataSource<Int, Setting>(
tableView: tableView,
cellProvider: self.cell(tableView:indexPath:setting:)
)
}()
init(settingsManager: SettingsManager) { // 1
self.settingsManager = settingsManager
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("no")
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
// boilerplate for autolayout.
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
// setting up the tableview to display cells
tableView.register(ToggleTableViewCell.self, forCellReuseIdentifier: "cell")
tableView.dataSource = dataSource // set it to use our diffable data source
// load settings data.
var snapshot = NSDiffableDataSourceSnapshot<Int, Setting>()
snapshot.appendSections([0])
snapshot.appendItems(settingsManager.settings())
dataSource.apply(snapshot)
}
func cell(tableView: UITableView, indexPath: IndexPath, setting: Setting) -> UITableViewCell? { // 2
guard let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as? ToggleTableViewCell else { return nil }
cell.textLabel?.text = setting.name
cell.toggle.isOn = setting.isEnabled
cell.onToggle = { [weak self] newValue in
self?.settingsManager.set(isEnabled: newValue, for: setting)
}
return cell
}
}
- I’m using dependency injection to give the
SettingsViewController
a known/controlledSettingsManager
. Dependency injection is a best practice for a number of reasons, and the reason here is that it enables the tests to control the other layers and domains that theSettingsViewController
communicates with. - This is where I dequeue and confige a
ToggleTableViewCell
with the settings, as well as set theonToggle
property to update theSettingsManager
.[weak self]
is used in the closure declaration to ensure that the cell doesn’t retain the view controller.
Testing this comes with it’s own set of concerns:
- The correct amount of cells should be shown
- Cells should be configured with the correct setting (the
textLabel
’stext
property is set to theSetting
’sname
property, and thetoggle
’sisOn
property is set to theSetting
’sisEnabled
property. - Toggling a switch should call
set(isEnabled:setting:)
on the givenSettingsManager
And we’ll handle each of these different behaviors in their own tests. This is done to show that each behavior is separate and that changes made during one test do not affect the results of a different test - also known as test pollution.
Before we write the tests, we need a small test helper. In more dynamic languages like Java or Objective-C, there exist libraries for creating stub or fake implementations of types on the fly. The closest thing in swift are scripts that will auto-generate fake implementations of protocols. Personally, I’d rather write my own fake implementation by hand.
class SimpleSettingsManager: SettingsManager {
var _settings: [Setting] = []
func settings() -> [Setting] { return _settings }
func set(isEnabled: Bool, for setting: Setting) {
guard let index = _settings.firstIndex(where: { $0.name == setting.name }) else { return }
_settings[index].isEnabled = isEnabled
}
}
Testing
Now that that’s out of the way, let’s write some tests. I’m going to first use XCTest
for this. Afterwards, I’ll show an example of how I’d write the same test in Quick
and Nimble
- my preferred testing frameworks. I prefer Quick and Nimble because they allow me to better model asynchronous and interactive behavior.
XCTest
import UIKit
import XCTest
class SettingsViewControllerTest: XCTestCase {
private func subjectFactory(settings: [Setting]) -> (subject: SettingsViewController, settingsManager: SimpleSettingsManager) { // 1
let manager = SimpleSettingsManager()
manager._settings = settings
let viewController = SettingsViewController(settingsManager: manager)
viewController.view.bounds = CGRect(x: 0, y: 0, width: 375, height: 667)
viewController.view.layoutIfNeeded() // 2
return (viewController, manager)
}
func testShowsCorrectAmountOfCells() {
let subject = subjectFactory(settings: [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: true),
Setting(name: "Baz", isEnabled: true),
]).subject
XCTAssertEqual(subject.tableView.numberOfSections, 1)
XCTAssertEqual(subject.tableView.numberOfRows(inSection: 0), 3) // 3
}
func testEachCellRepresentsASetting() throws {
let subject = subjectFactory(settings: [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: false)
]).subject
// 4
let fooCell = try XCTUnwrap(subject.tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? ToggleTableViewCell) // 4.1, 4.2
XCTAssertEqual(fooCell.textLabel?.text, "Foo") // 4.3
XCTAssertTrue(fooCell.toggle.isOn)
// 4.4: Repeat for testing the other cell.
let barCell = try XCTUnwrap(subject.tableView.cellForRow(at: IndexPath(row: 1, section: 0)) as? ToggleTableViewCell)
XCTAssertEqual(barCell.textLabel?.text, "Bar")
XCTAssertFalse(barCell.toggle.isOn)
}
func testTogglingUpdatesTheSettingsManager() throws {
let (subject, manager) = subjectFactory(settings: [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: false)
]) // 5
let fooCell = try XCTUnwrap(subject.tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? ToggleTableViewCell)
fooCell.toggle.isOn = false
fooCell.toggle.sendActions(for: .valueChanged) // 5.1
XCTAssertEqual(
manager._settings,
[
Setting(name: "Foo", isEnabled: false),
Setting(name: "Bar", isEnabled: false)
]
) // 5.2
// 5.3: Do it again with a cell that starts in the On position.
let barCell = try XCTUnwrap(subject.tableView.cellForRow(at: IndexPath(row: 1, section: 0)) as? ToggleTableViewCell)
barCell.toggle.isOn = true
barCell.toggle.sendActions(for: .valueChanged)
XCTAssertEqual(
manager._settings,
[
Setting(name: "Foo", isEnabled: false),
Setting(name: "Bar", isEnabled: true)
]
)
}
}
- I use the subject factory pattern here, instead of overriding and using
setup()
. This is mostly a style choice, but it does allow me to compose different subject factory functions depending on the needs of a test, while still overall sharing the setup code between each test. - Here I simulate a little bit of the view controller lifecycle. Specifically, I only need to cause the view to load, which is done by accessing the
view
property. Thebounds
of the view is then set so that thetableView
will have a non-zero size. Thebounds
is set to the iPhone 6s point resolution for no reason. Once thebounds
is set, a layout pass is forced, causes thetableView
to pick up the size of the view as it’s size (per autolayout). Thebounds
property is used (and not theframe
property) because we don’t care about whatever view contains this view controller’s view - it’s not being placed in a different view controller - and so it doesn’t make sense to set the frame because there is no superview’s coordinate system. The other reason to use thebounds
property is that thebounds
is not affected by thetransform
property. Which means that setting bounds will be valid regardless of what thetransform
is. Having the tests know as little as possible about the inner workings of the thing being tested is important for writing robust tests. - In
testShowsCorrectAmountOfCells
, I’m verifying the tableView shows the correct amount of cells. This is by first verifying that there’s only one section, and then verifying that there’s only as much cells in that section as there are settings objects. It’s important to use the actual value we expect to see. If the test wasXCTAssertEqual(subject.tableView.numberOfRows(inSection: 0), settings.count)
, then this is actually a weaker test because your test is not verifying the behavior you expect to see, but instead is duplicating the behavior. Put another way, it’s the difference betweenassert(2 + 4 == 6)
andassert(2 + 4 == 2 + 4)
. - In
testEachCellRepresentsASetting
, I’m now verifying the contents of the cells.- First, I’m getting each cell by their respective
IndexPath
. Note thatUITableView
’scellForRow(at:)
method returns nil both when the givenIndexPath
is outside of theUITableView
’s range, and when the requested cell is not visible. In other words,cellForRow(at:)
will only work for cells that have already been loaded. If you want to request a cell not yet visible (not within the bounds of theUITableView
), then you need to go directly to the underlyingUITableViewDataSource
, using thetableView(:cellForRowAt:)
method. I use this aslet cell = tableView.dataSource?.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0))
, which allows me to avoid publicly exposing whatever data source is used. - Immediately after retrieving the cell, I use
XCTUnwrap
to avoid force-casting.XCTUnwrap
will throw an error if the received value is nil, thus causing a test failure instead of crashing the entire test suite. This is done becauseXCTAssertTrue
/XCTAssertFalse
do not work with optionals. - Note that, like before, we assert that the cell’s
textLabel?.text
is equal to a static string instead of retrieving the appropriateSetting
instance and comparing it to that instance’sname
property. This is, again, done to avoid the mistake of tautological testing - your tests should not follow the same algorithm used to get the value you’re asserting on. - It’s important to note that you should unroll loops inside of your tests as much as possible, to make it obvious when and where a test failure is. This is not to say that you shouldn’t have assertion helpers/reusable tests, but, as with most things, it depends. When in doubt, err in favor of duplicated assertions.
- First, I’m getting each cell by their respective
- In
testTogglingUpdatesTheSettingsManager
, we’re verifying the interaction between the toggle switches and the settings manager. This is also the first test to actually use both objects returned in the tuple from thesubjectFactory
method, and that’s because it’s the first one where we’ll actually be examining the state of theSettingsManager
. This is going to start out looking much the same astestEachCellRepresentsASetting
, but instead we’re going to model what interacting with a switch should do.- This is part of the step of modeling what happens when the user toggles a switch - first I toggle the
isOn
property, then I tell the switch to send the actions for thevalueChanged
UIControl.Event
- as per theUISwitch
documentation. You’ll note that I do this instead of, say, calling theonToggle
property of the given cell. This is because the particular mechanism used to notify the view controller/model that the UI changed is an implementation detail and is tested by using theUISwitch
. In other words, by usingUISwitch.sendActions(for:)
, I’m indirectly asserting theonToggle
mechanism, in addition to asserting that theUISwitch
on theToggleTableViewCell
was correctly set up. This also has the additional benefit of making it easier to change how the toggle switch -> update SettingsManager in the future - I don’t need to update the tests and, done right, it’s a green-to-green refactor.
Sometimes, I create an extension on the more commonly used controls in my code base to consolidate the “update control state” and “send actions” calls.1 - This assertion is doing two things: It’s verifying the behavior that the correct arguments are passed to the
SettingsManager
’sset(isEnabled:setting:)
- which is the desired behavior to verify - and it’s also verifying that no other calls are made to the SettingsManager that would affect state (or at least, the other calls cancel each other out). I’ve also implemented fake implementations of properties such that they record the arguments and then I assert that the correct arguments are passed. With more complex protocols, or where the “Simple” implementation isn’t essentially an in-memory database, I will do that. But for something like this? It’s more of a style choice than anything else. - As with earlier, we do this again, for the other cell.
- This is part of the step of modeling what happens when the user toggles a switch - first I toggle the
That’s probably a bit overwhelming. But, it boils down to doing the minimum work to have UIKit call the methods that would be called if this were a real user interaction. The hard part is figuring out what that work is. For example, It took me a couple years before I realized that if I could make sure the tableView is actually displaying the cells I’m asking for, then I can ask the tableView
for those cells. Prior to that realization, I was always asking the tableView
’s dataSource
for the cells.
Quick
The implementation of these tests using Quick has slightly less repetition, and reads immensely better. The notes I write will be contrasting the Quick implementation of the test with the XCTest implementation, so read the above notes for any questions.
// I like to alphabetize my imports.
import Quick
import UIKit
import Nimble
class SettingsViewControllerSpec: QuickSpec {
override func spec() {
var subject: SettingsViewController!
var settingsManager: SimpleSettingsManager!
beforeEach {
settingsManager = SimpleSettingsManager()
settingsManager._settings = [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: false)
]
subject = SettingsViewController(settingsManager: settingsManager)
subject.view.bounds = CGRect(x: 0, y: 0, width: 375, height: 667)
subject.view.layoutIfNeeded()
}
it("has a row for each cell") { // 1
expect(subject.tableView.numberOfSections).to(equal(1))
expect(subject.tableView.numberOfRows(inSection: 0)).to(equal(2))
// 2
}
describe("the first cell") { // 3
var cell: ToggleTableViewCell?
beforeEach {
cell = subject.tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? ToggleTableViewCell
}
it("shows the first setting’s name") {
expect(cell?.textLabel?.text).to(equal("Foo"))
}
it("sets the toggle’s isOn to reflect the setting’s isEnabled") {
expect(cell?.toggle.isOn).to(beTrue())
}
context("when toggled") {
beforeEach {
cell?.toggle.isOn = false
cell?.toggle.sendActions(for: .valueChanged)
}
it("updates the settings manager with the new value") {
expect(settingsManager._settings).to(equal([
Setting(name: "Foo", isEnabled: false),
Setting(name: "Bar", isEnabled: false)
]))
}
}
}
describe("the second cell") {
var cell: ToggleTableViewCell?
beforeEach {
cell = subject.tableView.cellForRow(at: IndexPath(row: 1, section: 0)) as? ToggleTableViewCell
}
it("shows the second setting’s name") {
expect(cell?.textLabel?.text).to(equal("Bar"))
}
it("sets the toggle’s isOn to reflect the setting’s isEnabled") {
expect(cell?.toggle.isOn).to(beFalse())
}
context("when toggled") {
beforeEach {
cell?.toggle.isOn = true
cell?.toggle.sendActions(for: .valueChanged)
}
it("updates the settings manager with the new value") {
expect(settingsManager._settings).to(equal([
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: true)
]))
}
}
}
}
}
This makes the same assertions that the XCTest version does, but the setups aren’t repeated, and the individual assertions are broken up and given their own it
blocks. This also doesn’t group the toggling assertions in to a single test, and so the toggle interactions for each setting cell can be tested by themselves.
- In Quick and other rspec-like frameworks2, the
it
blocks are tests, similar totest*
functions inXCTest
. - The
expect(...).to(equal(...))
syntax comes from Nimble. I like this because it makes it provides some nice syntax sugar to differentiate what you’re asserting on, and what you expect the value to be. Theequal
,beTrue
,beFalse
functions are called Matchers, and Nimble has a pretty good API for writing custom matchers. This provides a much more declarative way for verifying views. - Where
it
blocks declare tests,describe
andcontext
blocks declare groups of tests. You can even nestdescribe
andcontext
blocks (but notit
blocks) as much as you’d like. This allows Quick to better match the event-driven asynchronous behavior.
I mentioned earlier that you should err on the side of duplicating tests, but this Quick spec demonstrates one of the areas where I would re-use assertions. I’d change the tests to look like this:
import Quick
import UIKit
import Nimble
class SettingsViewControllerSpec: QuickSpec {
override func spec() {
var subject: SettingsViewController!
var settingsManager: SimpleSettingsManager!
beforeEach {
settingsManager = SimpleSettingsManager()
settingsManager._settings = [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: false)
]
subject = SettingsViewController(settingsManager: settingsManager)
subject.view.bounds = CGRect(x: 0, y: 0, width: 375, height: 667)
subject.view.layoutIfNeeded() // 2
}
it("has a row for each cell") {
expect(subject.tableView.numberOfSections).to(equal(1))
expect(subject.tableView.numberOfRows(inSection: 0)).to(equal(2))
}
func itBehavesLikeACell(row: Int, name: String, isOn: Bool, updatedSettings: [Setting]) {
var cell: ToggleTableViewCell?
beforeEach {
cell = subject.tableView.cellForRow(at: IndexPath(row: row, section: 0)) as? ToggleTableViewCell
}
it("shows the first setting’s name") {
expect(cell?.textLabel?.text).to(equal(name))
}
it("sets the toggle’s isOn to reflect the setting’s isEnabled") {
expect(cell?.toggle.isOn).to(equal(isOn))
}
context("when toggled") {
beforeEach {
guard let toggle = cell?.toggle else { return }
toggle.isOn = !toggle.isOn
toggle.sendActions(for: .valueChanged)
}
it("updates the settings manager with the new value") {
expect(settingsManager._settings).to(equal(updatedSettings))
}
}
}
describe("the first cell") {
itBehavesLikeACell(
row: 0,
name: "Foo",
isOn: true,
updatedSettings: [
Setting(name: "Foo", isEnabled: false),
Setting(name: "Bar", isEnabled: false)
]
)
}
describe("the second cell") {
itBehavesLikeACell(
row: 1,
name: "Bar",
isOn: false,
updatedSettings: [
Setting(name: "Foo", isEnabled: true),
Setting(name: "Bar", isEnabled: true)
]
)
}
}
}
This does make the tests much harder to update if, for example, you wanted to assert on different behavior for some specific case. It’s also slightly harder to read. But it’s very helpful to reduce clutter when verifying the shared behavior of different objects.
Instead of using a function, you can also use Quick’s sharedBehavior
. You would pass in different arguments to the sharedBehavior
tests as a dictionary that’s created for each test. This has some benefits, but they do not outweigh the benefit of type safety that using a function provides.
And that’s an introduction to writing unit tests against UIKit. Hopefully this is enough to get started with simple interactions between a view and a model. Testing is a skill. It will be hard at first, but as you write more tests, it will get easier. You’ll start to internalize what it means to write a good test, and how to recognize a bad test and how it could be improved.
-
For example, my test helper for
UISwitch
looks like:import UIKit extension UISwitch { func toggle() { isOn = !isOn sendActions(for: .valueChanged) } }
Other
UIControl
’s are more complex, and, as such, I have more complex helpers for them. ↩ -
These are called rspec-like because rspec was the first framework (or at least, the first popular framework) with this style of branching syntax. They are also known as BDD frameworks. ↩