Testing Swift iOS Navigation

Photo of Patryk Strzemiecki

Patryk Strzemiecki

Updated Apr 19, 2024 • 23 min read
a woman looking at the code on the screen sitting in a restaurant

What do we mean by navigation? The first thing that comes to mind in the iOS context is UINavigationController with push and present methods, and that is correct.

But the whole navigation process may be implemented in various ways. Here I present a couple of examples of implementation and how to test them.

1. Directly calling push/present from the ViewController

This is the most common pattern used in simple iOS apps, View Controllers call the Navigation Controller directly.

Implementation

SomeViewController is a VC pushed onto our Navigation Controller. It will be our System Under Test with methods to call and test the result. This VC will be the owner of the Navigation Controller we mock in the test suite.

final class SomeViewController: UIViewController {
    func pushMyVC() {
        navigationController?.pushViewController(
            MyViewController(), animated: false
        )
    }
    
    func showMyVC() {
        navigationController?.present(
            MyViewController(), animated: false, completion: nil
        )
    }
}

Basic setup of the test suite + test cases

Test suite in the test target. It needs to set up a properly pushed view controller with a dependency on the mocked navigation controller.

We do not actually require UINavigationControllerMock to put any VCs on its navigation stack. It’s just an example. We can use navigationController.viewControllers or similar to check if it worked.

Test cases in BDD with asserts on the mocked navigation controller spies.

final class NavigationTests: XCTestCase {
    private var sut: SomeViewController!
    private var navigationController: UINavigationControllerMock!
    
    override func setUp() {
        super.setUp()
        
        sut = SomeViewController()
        navigationController
            = UINavigationControllerMock(rootViewController: UIViewController())
        navigationController.pushViewController(sut, animated: false)
        navigationController.pushViewControllerCalled = false
    }
    
    override func tearDown() {
        sut = nil
        navigationController = nil
        window = nil
        
        super.tearDown()
    }
    
    func test_givenVC_whenPushMyVCCalled_thenPushOnNavigationCalled() {
        sut.pushMyVC()
        
        XCTAssertTrue(navigationController.pushViewControllerCalled)
    }
    
    
    func test_givenVC_whenShowMyVCCalled_thenPushOnNavigationCalled() {
        sut.showMyVC()
        
        XCTAssertTrue(navigationController.presentCalled)
    }
}

Mocking the UINavigationController

Mock of the navigation controller testing with spy variables to be used in the test suite.

It calls the parent's class methods to keep the proper behavior. If needed, this way of mocking can be used with other methods of the UINavigationController as well.

final class UINavigationControllerMock: UINavigationController {
    var pushViewControllerCalled = false
    override func pushViewController(
        _ viewController: UIViewController, animated: Bool
    ) {
        super.pushViewController(viewController, animated: animated)
        pushViewControllerCalled = true
    }
    
    var presentCalled = false
    override func present(
        _ viewControllerToPresent: UIViewController,
        animated flag: Bool,
        completion: (() -> Void)? = nil
    ) {
        super.present(
            viewControllerToPresent, animated: flag, completion: completion
        )
        presentCalled = true
    }
}

2. Responsibility moved to one Coordinator/FlowCoordinator

In more sophisticated apps, it's common to abstract the flow and navigation. In most cases it still depends on the navigation controller, but is hidden by the Coordinator API.

Implementation

The implementation itself may seem more complex overall, but the view controller is slimmed down compared to the previous solution. Coordinator takes over all the navigation responsibilities and can be reused. Users could use the AppCoordinator adapter or create another one. Due to the default implementations of methods in the Coordinator's extension, when a new adapter does not implement a method it will assert.

final class SomeViewController: UIViewController {
    var coordinator: Coordinator?
    
    func pushMyVC() {
        coordinator?.myVC()
    }
}

protocol Coordinator {
    var parent: Coordinator? { get set }
    var navigationController: UINavigationController { get set }
    
    func myVC()
}

extension Coordinator {
    func myVC(file: StaticString = #file, line: UInt = #line) {
        assertionFailure("method: \(line) not implemented for: \(self)")
    }
}

final class AppCoordinator: Coordinator {
    var parent: Coordinator?
    var navigationController: UINavigationController
    
    init(
        parent: Coordinator? = nil,
        navigationController: UINavigationController
    ) {
        self.parent = parent
        self.navigationController = navigationController
    }
    
    func myVC() {
        navigationController.pushViewController(
            UIViewController(), animated: false
        )
    }
}

Basic setup of the test suite and an example test case

Setup is not much different than before. The Navigation Controller is not a property anymore, instead we use a Coordinator.

Test case in BDD with assert on the mocked coordinator spy:

final class NavigationTests: XCTestCase {
    
    private var sut: SomeViewController!
    private var coordinator: CoordinatorMock!
    
    override func setUp() {
        super.setUp()
        
        sut = SomeViewController()
        let navigationController
            = UINavigationController(rootViewController: UIViewController())
        coordinator = CoordinatorMock(navigationController: navigationController)
        sut.coordinator = coordinator
        navigationController.pushViewController(sut, animated: false)
    }
    
    override func tearDown() {
        sut = nil
        coordinator = nil
        window = nil
        
        super.tearDown()
    }
    
    func test_givenVC_whenPushMyVCCalled_thenCoordinatorCalled() {
        sut.pushMyVC()
    
        XCTAssertTrue(coordinator.myVCCalled)
    }
}

Mocking the Coordinator

Thanks to the Coordinator protocol, there may be only one mock for all the test suites. Same as before, we will use a spy property for assertions.
final class CoordinatorMock: Coordinator {
    var parent: Coordinator?
    var navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    var myVCCalled = false
    func myVC() {
        myVCCalled = true
    }
}

Testing an example flow with a Coordinator pattern.

Implementation

Here we have two View Controllers that use a Coordinator and a parent Coordinator. When reaching the Details View Controller, there is no way to go back to the Home View Controller if the coordinator of details has no parent. The Coordinator should keep a reference to a parent and encapsulate the logic of the flow.
final class HomeViewController: UIViewController {
    var coordinator: Coordinator?
    
    func start() {
        coordinator?.details()
    }
}

final class DetailsViewController: UIViewController {
    var coordinator: Coordinator?
    
    func finished() {
        coordinator?.home()
    }
}

protocol Coordinator {
    var parent: Coordinator? { get set }
    var navigationController: UINavigationController { get set }
    
    func home()
    func details()
}

extension Coordinator {
    func home() { logFailure() }
    func details() { logFailure() }
}

private extension Coordinator {
    func logFailure(file: StaticString = #file, line: UInt = #line) {
        assertionFailure("method: \(line) not implemented for: \(self)")
    }
}

final class AppCoordinator: Coordinator {
    var parent: Coordinator?
    var navigationController: UINavigationController
    
    init(
        parent: Coordinator? = nil,
        navigationController: UINavigationController
    ) {
        self.parent = parent
        self.navigationController = navigationController
    }
    
    func details() {
        let vc = DetailsViewController()
        vc.coordinator = AppCoordinator(
            parent: self, navigationController: navigationController
        )
        navigationController.pushViewController(vc, animated: false)
    }
    
    func home() {
        guard parent != nil else {
            fatalError("cannot show home when no parent passed")
        }
        navigationController.popViewController(animated: false)
    }
}

Test suite

Yet another approach without mocking the Coordinator but checking only the actual navigation stack.

final class NavigationTests: XCTestCase {
    
    private var sut: HomeViewController!
    private var navigationController: UINavigationController!
    
    override func setUp() {
        super.setUp()
        
        sut = HomeViewController()
        navigationController
            = UINavigationController(rootViewController: UIViewController())
        sut.coordinator = AppCoordinator(navigationController: navigationController)
    }
    
    override func tearDown() {
        sut = nil
        navigationController = nil
        
        super.tearDown()
    }
    
    func test_givenVC_whenStartCalled_thenDetailsVCAddedToNavigationStack() {
        sut.start()
        
        XCTAssertTrue(navigationController.children.last is DetailsViewController)
    }
    
    func test_givenVC_whenStartCalled_andFinishedCalled_thenDetailsPoppedFromNavStack() {
        sut.start()
        
        let detailsVC
            = navigationController.children.last as! DetailsViewController
        detailsVC.finished()
        
        XCTAssertEqual(navigationController.children.count, 1)
    }
    
    func test_givenVC_whenStartCalled_thenVCPushedToNavStack() {
        sut.start()
        
        XCTAssertEqual(navigationController.children.count, 2)
    }
}

3. Responsibility moved to a single Router for VC

Implementation

Navigation with Routers is one of the most cohesive ways of handling the navigation flow in iOS apps. The implementation is a mix of the Coordinator’s simplicity and a native Navigation Controller.

final class SomeViewController: UIViewController {
    var router: SomeViewRouter?
    
    func pushMyVC() {
        router?.myVC()
    }
}

protocol SomeViewRouter {
    func myVC()
}

final class SomeViewRouterImp: SomeViewRouter {
    var source: UIViewController?
    
    func myVC() {
        source?.navigationController?.pushViewController(
            UIViewController(), animated: true
        )
    }
}

Basic setup of the test suite + test case

Test suite here is the cleanest in this tutorial. No need to create a Navigation Controller here. It should be mocked and tested in the router test suite.

Test case in BDD with assert on the mocked router spy:
final class NavigationTests: XCTestCase {
    
    private var sut: SomeViewController!
    private var router: SomeViewRouterMock!
    
    override func setUp() {
        super.setUp()
        
        router = SomeViewRouterMock()
        sut = SomeViewController()
        sut.router = router
    }
    
    override func tearDown() {
        sut = nil
        router = nil
        
        super.tearDown()
    }
    
    func test_givenVC_whenPushMyVCCalled_thenRouterCalled() {
        sut.pushMyVC()
        
        XCTAssertTrue(router.myVCCalled)
    }
}

Mocking the router

The Some View Router is only to be used with this test suite, but it's easy to set up and make spies as we do not use any dependencies for the purposes of the test suite. Only spies should be created and used here.
final class SomeViewRouterMock: SomeViewRouter {
    var myVCCalled = false
    func myVC() {
        myVCCalled = true
    }

Summary

Of course, the examples do not cover all possible solutions. Especially now that we have SwiftUI, there will be many more ways to implement and test navigation in iOS. But I hope this article helps you to keep your code tested or at least give you a good start to improve your project.

Photo of Patryk Strzemiecki

More posts by this author

Patryk Strzemiecki

Senior iOS Developer
codestories

We're Netguru!

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency
Let's talk business!

Trusted by: