Testing view controllers isn’t as hard as people think. Using Quick, Rachel Bobbins shows how useful testing can be, covering different testing patterns she and her team use such as buttons triggering network requests, handling successful and failing response cases, and properly presenting other view controllers.
Introduction (0:00)
I’m Rachel Bobbins, and I’m a software engineer at Stitch Fix. Before Stitch Fix, I worked as a software engineer at Pivotal Labs. I do a lot of work on iOS apps.
Recently, I interviewed a candidate who was really adamant about telling me, “Everyone knows you can’t test view controllers, that’s stupid.” It was pretty unfortunate, but it motivated me to give this talk. It seems like a lot of people don’t test view controllers because they think that testing them is hard. I’m hoping to show you today that it is not hard. It’s really simple, and you should probably do it.
Good Testing Leads to Good UX (0:35)
Why should you care about testing view controllers? They’re the level of code that’s closest to controlling the user experience. If you care about your users having a good experience, you should make sure to control for it as much as possible, and tests are a really easy, low cost way of doing that. Testing now will also save you time further down the road.
One really common argument I’ve heard against testing view controllers is, “I don’t need them because I have UI tests/KIF tests.” It’s true that KIF tests and UI tests are really good for testing the happy paths of your application, but they also take a really long time to run. If your tests take a long time to run, then you’re not going to run them as part of your regular development process, and they will therefore become useless.
View controller tests are really awesome because they let you test all the small details that you might not think about testing inside a UI test or a KIF test. Furthermore, they let you test more of the different divergent paths that your code can go through, including error cases or slow network cases.
Demo App (1:59)
I have a demo app that I built for the upcoming code samples, and the code is available on GitHub, so check that out. You can also watch me demo the app in the video above. It’s really straightforward.
If you are familiar with the @earthquakesSF Twitter account, you’ll know that it’s an automated account that tweets about earthquakes in the Bay Area. I noticed that last weekend, there were many earthquakes happening in the San Ramon area. I built an app that will give you this information as well. Basically, there’s a button that says “Tap for earthquakes”, which will tell you how many earthquakes there have been in San Ramon since October 1st. You can also tap “Try again” to see if there have been any more since then. It’s really simple: it makes a network request, and it shows an alert with the result of that network request.
Quick Framework (3:10)
The code examples you’re going to see are all using the Quick testing framework. A lot of people have strong ideas about which testing framework they like to use, but the principles I’m going to mention can also be applied with XCTest, Cedar, or whatever other testing framework you might be using.
I really like Quick because it lets you write behavioral style tests. That means you can have nesting and shared setup, and your tests read more like the flows which your users actually go through.
Principle 1: Test the Interface (3:59)
I’m going to give you a couple of principles, along with some tips for how to actually apply each of them to your tests.
Principle 1 is to test the interface. In most unit tests, you’re actually testing the public methods on the object that can be called by other objects, and you test the return values or the side effects of those functions. However, in your view controller, you shouldn’t have other objects calling methods on the view controller. So, when we talk about testing the interface here, we’re not talking about the code interface, rather we’re talking about the user interface. You want to write your tests from the perspective of how a user interacts with the view that this view controller controls.
A pre-recorded look at how how this works is available here. The video is an earlier version of the demo app, and it’s really simple. It has a button on it, and the button starts and stops, toggling a spinner.
import Quick
import Nimble
@testable import EarthquakeCounter
class WelcomeViewControllerSpec: QuickSpec {
override func spec() {
var subject: WelcomeViewController!
beforeEach {
subject = WelcomeViewController()
//Trigger the view to load and assert that it's not nil
expect(subject.view).notTo(beNil())
expect(subject.welcomeLabel).notTo(beNil())
}
it("says welcome") {
expect(subject.welcomeLabel.text).to(equal("Welcome!"))
}
it("has a spinner that is not moving") {
expect(subject.spinner.isAnimating()).to(beFalse())
}
describe("toggleSpinner:") {
beforeEach {
Subject.toggleSpinner(subject.spinnerButton)
}
it("animates the spinner") {
expect(subject.spinner.isAnimating()).to(beTrue())
}
describe("toggleSpinner: again") {
beforeEach {
subject.toggleSpinner(subject.spinnerButton)
}
it("stops the spinner") {
expect(subject.spinner.isAnimating()).to(beFalse())
}
}
}
}
}
First, we’re going to look at a traditional unit test. We’ve got a method called toggleSpinner
, so we test that calling that method gets to the spinner to start animating, and then we test that calling that method again stops the spinner. If you haven’t used Quick before, some of this might not be super intuitive, but basically each it
is a test, and then the describe
has a beforeEach
which is a shared setup for the tests within that scope.
That’s the structure for all of these tests. Our tests will pass right now, if we looked at our app, we would see that it behaves exactly as expected: tapping starts the spinner, and then tapping again stops the spinner.
However, it’s really easy to break these tests and have them break the test or break the app. You can get get it into a state where your tests are passing, but your app is totally broken. That’s not a state that you ever want to be in, because your tests are rendered useless.
If I break the app by deleting the outlet and creating a new function (didTapSpinnerButton
, which won’t do anything because it’s not hooked up to anything), we’ll see that the app is broken as expected. Even so, if I were to run the tests, I would see that they all continue to pass.
We have passing tests but a broken app. How can we make this better? Let’s change…
describe("toggleSpinner:") {
beforeEach {
Subject.toggleSpinner(subject.spinnerButton)
}
to…
describe("tapping the button") {
before each {
subject.spinnerButton.sendActionsForControlEvents(.TouchUpInside)
}
First, name your tests in a way that describes behavior, not methods. For example, don’t name it after a function. If you name it after a function and you’re testing a function, it will be a brittle test. In this case, the behavior we’re testing is tapping the button. The description, the expectation, should describe exactly what we expect from the user’s perspective.
Second, write a “tap” helper. Instead of calling a toggleSpinner
, we actually call sendActionForControlEvents(.TouchUpInside)
That’s exactly what happens when you tap the button in real life. Really, you should mimic those interactions, and it’s really useful to write helpers that abstract some of this away for you. It will make your tests even more readable. The action you’re taking in your test matches the description, which is pretty cool.
In summary, test the interfaces from the perspective of the person or object who is consuming the view controller.
Principle 2: Inject All the Dependencies (8:29)
One argument I’ve heard against testing view controllers is that, “I keep my view controllers really thin. I don’t need to test them because they don’t have any logic.” Well, that’s not really true. It’s really important that your view controllers actually pass messages to those objects that contain all of the logic. If you lose or break a line of code that sends a message to the logic object, your app is broken. When I say inject all the dependencies, I mean inject anything that contains any logic so that your tests are going to test that those objects get consumed correctly.
self.presentViewController(alert, animated: true, completion: nil)
Here’s an example. We have a line of code here which calls presentViewController
. It should pass the following test, right?
it("presents an alert") {
expect(subject.presentedViewController).to(beAnInstanceOf(UIAlertController))
}
…NOPE. It won’t pass. It turns out that UIkit, the one who presents the view controller, has a hidden dependency which is that you can’t present an alert controller because the view is not in the window hierarchy.
This here is kind of an implicit dependency, because you don’t realize you have this dependency on UIKit until your tests don’t pass. An example of a dependency you can inject in this case is a DialogPresenter
.
public protocol DialogPresenter {
func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presenter: UIViewController)
}
class RealDialogPresenter: DialogPresenter {
func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presentingViewController: UIViewController) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .Alert)
let cancelAction = UIAlertAction(title: "Cancel", style: .Cancel, handler: nil)
alert.addAction(cancelAction)
let tryAgainAction = UIAlertAction(title: "Try Again", style: .Default) { _ in onTryAgain() }
alert.addAction(tryAgainAction)
presentingViewController.presentViewController(alert, animated: true, completion: nil)
}
}
It has a single function, which is called present
. We present the alert that we’ve just created. The really cool thing about this is that this dependency conforms to a protocol, which means that in our test, we can fake it out.
Use protocols. If a view controller depends on it, then in addition to injecting it, it also needs to conform to a protocol. That makes it super easy to write fakes that you can use in your tests.
class FakeDialogPresenter: DialogPresenter {
var present_wasCalled = false
var present_wasCalled_withArgs: (title: String, message: String?, onTryAgain: (Void -> Void), presentingVC: UIViewController)? = nil
func present(title: String, message: String?, onTryAgain: (Void -> Void), onTopOf presenter: UIViewController) {
present_wasCalled = true
present_wasCalled_withArgs = (title: title, message: message, onTryAgain: onTryAgain, presentingVC: presenter)
}
}
This is what the fake for this would look like. It’s a “FakeDialogPresenter”.
Invest in fakes. They can take some time and a little bit of maintenance, but they’re really worth it. The two things I do in all my fakes is to have a boolean property to keep track of which methods were called on the fake, so that I know that my view controller is actually telling the fake to do what it’s supposed to do.
I also like to have a tuple that keeps track of all the arguments that were passed to this object. This was really, really useful in Objective-C, because you didn’t have as much of the type safety as you do with Swift. Some people might argue that it’s not that necessary with Swift, because you know that it’s always gonna get called with a string, otherwise you’d get a compiler warning. However, I think it’s still useful if you have a model that you’re passing around, making sure that you’re passing the model with the correct properties and all that jazz.
With a fake like this, you can write a test that reads like an English sentence. Here’s an excerpt of the test.
it("presents an alert") {
expect(dialogPresenter.present_wasCalled).to(beTrue())
}
describe("the alert") {
it("includes the correct text") {
let expectedTitle = "Earthquakes near San Ramon"
let actualTitle = dialogPresenter.present_wasCalled_withArgs?.actualTitle
expect(actualTitle).to(equal(expectedTitle))
let expectedMessage = "There have been 0 earthquakes near San Ramon since 10/1/2015"
let actualMessage = dialogPresenter.present_wasCalled_withArgs?.actualMessage
expect(actualMessage).to(equal)(expectedMessage))
}
}
It had previously failed, but with the fake, we can write tests that read like English sentences so we expect that we told the dialogPresenter
to present, and we expect that it was called with a certain bit of argument.
And that’s how you test view conrollers!those are my ideas about how you test view controllers.
FYI, PivotalCoreKit is something that I used a lot when I worked at Pivotal, and there are a lot of helpers in there that you can use for your tests. Check it out!
Q&A (13:39)
Q: What are the limitations you’ve found with this framework?
Rachel: The main drawback is that its integration with Xcode is sometimes buggy. For example, the sidebar that lists all of your tests sometimes doesn’t work. For me, that’s okay, because this lets me write the types of tests that I find most useful, and that is more useful to me overall than the UI of Xcode being functional.
Q: Does Quick actually work outside the simulator? Can you plug in device and then have Quick actually test the device? I wonder because a year ago, it only worked with only simulators.
Rachel: Yeah, I’ve run my tests on device, no problem.
Q: I’m curious, do you have a chance to use MVVM architecture for arranging your applications? In MVVM, most of the logic goes away from the view controller, and as a result, this type of a test will be helpful to verify bindings logic in the UI components…
Rachel: I have not used MVVM before, but my instinct is always that tests are useful.
Q: What’s a suggestion you can give when you’re in an organization where testing is not a belief? How do you drive that TDD spirit up?
Rachel: I think it’s hard if you’re the only engineer on the team who buys into it. However, in my case, I’ve been able to show by example. Basically, if you write tests, other people will eventually start seeing that they’re useful (hopefully). That’s a tough one!
Q: I’ve found that there were often times where I would have to call loadView
or viewDidLoad
. Are there specific times you find that’s the case, or do you have any suggested work arounds for doing your setup?
Rachel: That’s a good point. Most of the setup does happen somewhere around viewDidLoad
, so you do have to trigger that cycle. My personal preference is to always put at the beginning of my test, expect(subject.view).notTo(beNil())
. That triggers the loading, but it’s also an expectation, and you now that if that fails, something’s really wrong with your tests. It both triggers the setup that you need and acts as a sort of canary in the coal mine type of test.
Receive news and updates from Realm straight to your inbox