DEV Community

Gio Lodi
Gio Lodi

Posted on • Updated on • Originally published at mokacoding.com

How to test view controllers navigation

Here's a question that often comes up when testing iOS application: "How can I test that a view controller presents another view controller when something happens?"

Testing navigation between view controllers can be tricky.
You need to wrap your view controller under test in a UINavigationController if you want to test a push navigation or add it to a UIWindow if for a modal presentation.
There are usually animations involved, which in turn require either your test assertions to be asynchronous or your code to allow the animated flag to be injected as false from the tests.

Those and other little gotchas make the tests harder to write and reason about, but it doesn't have to be so.

You can use a simple and cheap pattern to easily test how your view controllers trigger navigation, making your code easier to work with in the process.

NavigationDelegate

The key to make navigations between view controllers easy to test lies in appreciating that view controllers be responsible for presenting other view controllers.

View controllers should only trigger the navigation and then delegate the act of performing it to another object.
I like to call this kind of delegates NavigationDelegates*.

With a NavigationDelegate in place, testing how a view controller does navigations is now a matter of testing how it interacts with its delegate.
This can be done by using a spy.

NavigationDelegate in action

To test that a view controller triggers the expected navigation you can verify that it calls the appropriate method of its NavigationDelegate.

func testCallsShowRedWhenRedButtonTouched() {
    let viewController = loadViewController()
    let navigationDelegateSpy = ViewControllerNavigationDelegateSpy()
    viewController.navigationDelegate = navigationDelegateSpy

    viewController.redButton.sendActions(for: .touchUpInside)

    XCTAssertTrue(navigationDelegateSpy.showRedCalled)
}
Enter fullscreen mode Exit fullscreen mode
protocol ViewControllerNavigationDelegate: class {
    func showRedViewController()
}

class ViewController: UIViewController {

    @IBOutlet weak var redButton: UIButton!

    weak var navigationDelegate: ViewControllerNavigationDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()

        redButton.addTarget(self, action: #selector(redButtonTouched), for: .primaryActionTriggered)
    }

    @objc func redButtonTouched() {
        navigationDelegate?.showRedViewController()
    }
}
Enter fullscreen mode Exit fullscreen mode
class ViewControllerNavigationDelegateSpy: ViewControllerNavigationDelegate {

    private(set) var showRedCalled = false

    func showRedViewController() {
        showRedCalled = true
    }
}
Enter fullscreen mode Exit fullscreen mode

All the this code is available in this GitHub repo.

The tests above look simple, don't they?
By using this pattern, you remove the need to set up the whole navigation stack containing the view controller under test to verify it's behavior.
You also don't need to put the view controller inside a UIWindow or UINavigationController, simplifying the test setup and tear down.

The only downside is that you have to build a spy test double for the NavigationDelegate.
Given how little code this involves, I think it's a pretty good tradeoff.

If you want to test how the type conforming to the NavigationDelegate performs the navigation -a wise thing to do- you can do that in isolation.

func testPushesRedViewController() {
    let navigator = Navigator(navigationController: UINavigationController(rootViewController: UIViewController()))

    navigator.showRedViewController()

    _ = expectation(
        for: NSPredicate { input, _ in
            return (input as? UINavigationController)?.topViewController is RedViewController
        },
        evaluatedWith: navigator.navigationController,
        handler: .none
    )
    waitForEnavigatorpectations(timeout: 1, handler: .none)
}
Enter fullscreen mode Exit fullscreen mode

You can adopt NavigationDelegates today

What I love about this idea is that it is cheap and portable.
You don't have to change your application architecture to adopt it.
In fact, it fits well with most architectures.

You can start using NavigationDelegates today.

To get started and make your code testable you can even have the view controller be its own navigation delegate.
This is definitely not something I would endorse in the long run, but it might help you get started.
Once the delegate is in the codebase, you can come back to it later and extract the implementation in a dedicated object.

Benefits

Making the navigation side of your view controllers easier to test this way comes with other benefits too.

Because your view controllers are now unaware of how to present new view controllers, they won't need to change when updating the way navigation works (see this PR in the example project).

Remember the definition of the single responsibility principle? An object should only have one reason to change.
View controllers are the glue code of iOS applications; they end up doing a lot of things.
With a NavigationDelegate they have one less thing to do. 😉

Single responsibility is also why I prefer to use a dedicated delegate for navigation only, even if the view controller already has a delegate.
Yes, you end up with more definitions and objects, but I'd rather work in a codebase with a lot of small and easy to reason about pieces, than one with a handful of classes doing all the work.


If you know the right approach, testing how a UIViewController triggers a navigation is as simple as verifying any other side effect code.

The NavigationDelegate pattern follows the dīvide et imperā rule; divide and conquer.
Split the implementation of the navigation in two pieces, the code that starts it and the code that performs it.
Testing both in isolation is easier than testing them together.

Using NavigationDelegates will not only make a critical area of your app such as how screens are linked together easier to test, but it will also make it easier to reuse view controllers in different areas of the application.
A case in point for how making things easy to test also makes them easy to use.

The pattern also scales nicely.
You can imagine a view controller triggering the presentation of a different screen based on some internal state.
For example, a button could present a screen to up-sell the user to a subscription if they don't have one or push to the feature screen if they have it.
These two different flows are triggered by two different methods in a NavigationDelegate.
In the tests, you can set the state in the view controller, synthesize the user action that should trigger the presentation, and verify with a spy that the correct navigation method is called.

What do you think of this approach? I'd love to hear your thoughts if you use it, and the spin that you'll put on it.
Get in touch on Twitter at @mokagio or leave a comment below.

This is a crosspost from my blog mokacoding.com.

Related Posts

Footnotes

*. The idea of delegating the responsibility of performing navigations from a view controller is not original.
This concept is what makes patterns like Coordinators, Navigators, and Routers possible.
The first time I saw this formalized was in Alberto De Bortoli's 2014 post "Flow Controllers on iOS for a better navigation control".
Although he injects the object responsible for the navigation in the view controller, the result is pretty much the same.

Top comments (2)

Collapse
 
tom0pearson profile image
Tom Pearson

It makes a lot of sense. How do you feel about coordinators?

Collapse
 
mokagio profile image
Gio Lodi

Thanks Tom 🙂.

I think Coordinators are a great pattern to organize the flow of the screens between apps.

In fact, you could make the Coordinator responsible for the flow in which your ViewController is its NavigationDelegate and have the best of both worlds.