There are different ways to test that the UIViewController has been successfully pushed onto the navigation stack and in this tutorial, I will cover a few of them.
For this tutorial, I have created a simple project with two view controllers on the Main.storyboard. The very first view controller has a button. When the button is tapped, it takes a user to the second view controller by pushing it into a navigation stack.
If you are interested in video lessons on how to write Unit tests and UI tests to test your Swift mobile app, check out this page: Unit Testing Swift Mobile App
The implementation of the first view controller is very basic. It only handles a button tap event.
import UIKit class ViewController: UIViewController { @IBOutlet weak var nextViewButton: UIButton! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } @IBAction func nextViewButtonTapped(_ sender: Any) { let storyboard = UIStoryboard(name: "Main", bundle: nil) let secondViewController = storyboard.instantiateViewController(withIdentifier: "SecondViewController") as! SecondViewController self.navigationController?.pushViewController(secondViewController, animated: true) } }
The Test Case Class
To unit test that the view controller is pushed to a navigation controller, I have first created the Unit Test Case class that loads the first ViewController class from the main storyboard. To learn about different ways you can load a UIViewController into test case class, have a look at this tutorial: Ways to load a UIViewController in a Unit Test.
import XCTest @testable import TestNavigationTutorial class TestNavigationTutorialTests: XCTestCase { var sut: ViewController! var navigationController: UINavigationController! override func setUpWithError() throws { // Step 1. Create an instance of UIStoryboard let storyboard = UIStoryboard(name: "Main", bundle: nil) // Step 2. Instantiate UIViewController with Storyboard ID sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController // Step 3. Make the viewDidLoad() execute. sut.loadViewIfNeeded() navigationController = UINavigationController(rootViewController: sut) } override func tearDownWithError() throws { sut = nil navigationController = nil } }
Test Navigation with Predicate and Expectation
In the code snippet below I use the Predicate and an Expectation to test if the second view controller has been successfully pushed into a navigation controller.
Although it works well, I do not like this approach much because it makes my unit test wait for almost 2 seconds for an expectation to successfully fulfill. This is because when the button is tapped, due to animation, it takes a little time for a second view controller to be pushed into the navigation stack. This does not take place immediately. The wait time of 2 seconds in my opinion is too long. If I have to test the navigation between 20 view controllers then the wait time only is about 40 seconds. I would better mock the navigation controller then.
func testNextViewButton_WhenTapped_SecondViewControllerIsPushed() throws { // Create a Predicate to evaluate if the TopViewController is a SecondViewController let myPredicate = NSPredicate { input, _ in return (input as? UINavigationController)?.topViewController is SecondViewController } // Create Expectation for a Predicate. // This will make our unit test method, continously evalulate the // predicate until it matches or expectation times out. self.expectation(for: myPredicate, evaluatedWith: navigationController) // Act sut.nextViewButton.sendActions(for: .touchUpInside) waitForExpectations(timeout: 2) }
Test Navigation – a better approach
The below code snippet does not wait for a predicate condition to successfully evaluate and simply runs the RunLoop one more time. It works much faster.
In the code snippet below if the topViewController is not the SecondViewController, then the XCTFail() will fail the test method. Otherwise, the execution will continue and the test method will simply pass.
func testNextViewButton_WhenTapped_SecondViewControllerIsPushed2() throws { // Act sut.nextViewButton.sendActions(for: .touchUpInside) RunLoop.current.run(until: Date()) // Assert guard let _ = navigationController.topViewController as? SecondViewController else { XCTFail() return } }
Test Navigation. An approach with a Spy object
Another way to learn if the view controller was pushed into a navigation controller is to spy on the pushViewController() method. This way you can avoid any waiting time and not even need to run the RunLoop. Although I still like the approach with the RunLoop as it seems to be simpler and always works.
Creating a Spy Class for UINavigationController
import UIKit class SpyNavigationController: UINavigationController { var pushedViewController: UIViewController? override func pushViewController(_ viewController: UIViewController, animated: Bool) { pushedViewController = viewController super.pushViewController(viewController, animated: true) } }
Unit Test Method
func testNextViewButton_WhenTapped_SecondViewControllerIsPushed3() throws { // Wrap ViewController into a Spy version of Navigation Controller let mockNavigationController = SpyNavigationController(rootViewController: sut) // Act sut.nextViewButton.sendActions(for: .touchUpInside) // Assert guard let _ = mockNavigationController.pushedViewController as? SecondViewController else { XCTFail() return } }
I hope this short tutorial was helpful to you. If you are interested to learn more about Unit testing and UI Testing of your Swift mobile application, then have a look at my video lessons here – “Unit Testing Swift Mobile App“.
Happy Unit Testing! 🙋🏻♂️