Testing Swift iOS Navigation
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.