SwiftUI - Extract navigation logic from SwiftUI views for testability

Problem: How to test your navigation logic in SwiftUI ?

Solution: Extract navigation logic in a separate Service that handles it using UIKit

SwiftUI is very powerful tool, for UI creation and animation
But I really don't like the navigation logic
That is couples with SwiftUI views with NavigationLink
Make it difficult to test
The idea here is to extract the navigation logic in a separate service
So SwiftUI views only have to use it
And doesn't know anything about navigation logic

I draw a diagram of the navigation logic
To better understand what we are trying to achieve
image.png

Here we handle the navigation logic with UIKit
So the navigation bar will be a UINavigationBarController
Everything related with navigation bar:
Title, back button, left button item
Will be created using UIKit api (UIAlertController, UIBarButtonItem, ...)
Only navigation bar visibility will be handle by the SwiftUI view
Using the modifier .navigationBarHidden(true)
Because when try to handle visibiity from UIKit
I encountered crashes

From this diagram
And the UIKit requirement
I have a better idea of what I'm trying to achieve
I'm able to write those specs:

 ========= LOGIN
 [ ] On init user not login set login view as root with no animation
   [ ] User first login push to the onboarding view
     [ ] Validate onboarding set the infos view as root with no animation
 [ ] On init user login set infos view as root with no animation

 ========= INFOS
 [ ] Infos can push to info details view
 [ ] Infos nav bar have a title
 [ ] Infos nav bar have a right button for logout
   [ ] Infos nav bar right button action present an alert sheet
     [ ] Alert sheet have a title (no message)
     [ ] Alert sheet have a cancel action (user can dismiss)
     [ ] Alert sheet have a logout action
       [ ] Confirm logout set login as root with no animation
         [ ] Login user after a logout set infos view as root (no onboarding)

 ========= INFOS DETAILS
 [ ] Infos details can push to new info view
 [ ] Infos details nav bar have a title
 [ ] Infos details nav bar have a back button (user can pop back)
 [ ] Infos details nav bar have a right button for create new info

 ========= NEW INFOS
 [ ] New info nav bar have a title
 [ ] New info nav bar have a back button (user can pop back)
 [ ] Create new info pop to the root view (infos view)

Let's start with the first requirement for login

 ========= LOGIN
 [ ] On init user not login set login view as root with no animation

Start writing the test

  func test_init_userIsNotLogin_setLoginViewAsRootWithNoAnimation() {
    let userIsNotLogin = false
    let navigationSpy = UINavigationControllerSpy() // 1

    _ = NavigationService(navigationController: navigationSpy, // 2
                          isLogin: userIsNotLogin)

    XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<LoginView>, "Expect login view as root view controller")
    XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the login view on init")
  }

The compiler is guiding us

  1. He cannot find UINavigationControllerSpy in scope we need to create it
  2. He cannot find NavigationService in scope we need to create it

Create the UINavigationControllerSpy first
This is a spy because it just capture values

private class UINavigationControllerSpy: UINavigationController {
    var controllers = [UIViewController]()
    var isAnimate = false

    override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
      self.controllers = viewControllers
      self.isAnimate = animated
    }
}

Now create the NavigationService

class NavigationService: ObservableObject {
  let isLogin: Bool
  let navigationController: UINavigationController
  init(navigationController: UINavigationController, isLogin: Bool) {
    self.navigationController = navigationController
    self.isLogin = isLogin

    if !isLogin {
      setLoginViewAsRoot()
    }

  // MARK: Helpers
  private func setLoginViewAsRoot() {
    // Do nothing want to see failed test first
  }
}

Run the test and It fails
XCTAssertTrue failed - Expect login view as root view controller

Let's make it pass
Setting the Loginview as root view controller

  private func setLoginViewAsRoot() {
    let loginViewController = UIHostingController(rootView: LoginView()) // 1
    navigationController.setViewControllers([loginViewController], animated: false)
  }
  1. View controller is created from SwiftUI view using UIHostingController

Run the test it pass
Can checkmark this requirement as done

 ========= LOGIN
 [✅] On init user not login set login view as root with no animation

Continue with the second requirement for login

 ========= LOGIN
   [ ] User first login push to the onboarding view

As usual writing the test first

  func test_login_userIsFirstLogin_pushToOnboardingViewWithAnimation() {
    let userIsFirstLogin = true
    let userIsNotLogin = false
    let navigationSpy = UINavigationControllerSpy()

    let sut = NavigationService(navigationController: navigationSpy,
                                isLogin: userIsNotLogin,
                                isFirstLogin: userIsFirstLogin) // 1

    sut.login() // 2

    XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<OnboardingView>, "Expect onboarding view visible")
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display onboarding view")
  }
  1. The compiler is guiding us with this error:
    Extra argument isFirstLogin in call We need to add the isFirstLogin parameter.
  2. The login() function not exist we also need to create it
class NavigationService: ObservableObject {
  ...
  var isFirstLogin: Bool

  init(navigationController: UINavigationController, isLogin: Bool, isFirstLogin: Bool) {
    ...
    self.isFirstLogin = isFirstLogin
    ...
  }

  func login() {
    if isFirstLogin {
      displayOnboardingView()
    }
  }
  ...
  // MARK: Helpers
  ...
  private func displayOnboardingView() {
    // Do nothing want to see a test that failed first
  }
}

Xcode can now compile
Run the test
It crash
Fatal error: Index out of range

That is normal we need to update the UINavigationControllerSpy

private class UINavigationControllerSpy: UINavigationController {
  ...
  override func pushViewController(_ viewController: UIViewController, animated: Bool) {
      self.controllers.append(viewController)
      self.isAnimate = animated
    }
  ...
}

And also the navigation that push the view

  private func displayOnboardingView() {
    let onboardingViewController = UIHostingController(rootView: OnboardingView())
    navigationController.pushViewController(onboardingViewController, animated: true)
  }

Run the test it pass
Can mark this spec as done

 ========= LOGIN
 [✅] User first login push to the onboarding view

To simply the test setup
Extract the sut creation to an helper method

class SwiftUINavigationTests: XCTestCase {
  ...
  // MARK: Helpers

  private func makeSUT(isLogin: Bool = true, isFirstLogin: Bool = false) -> (NavigationService, UINavigationControllerSpy) {
    let navigationSpy = UINavigationControllerSpy()
    let navigationService = NavigationService(
      navigationController: navigationSpy,
      isLogin: isLogin,
      isFirstLogin: isFirstLogin
    )
    return (navigationService, navigationSpy)
  } 
}

You can use it like this for example

  func test_login_userIsFirstLogin_pushToOnboardingViewWithAnimation() {
    let userIsFirstLogin = true
    let (sut, navigationSpy) = makeSUT(isLogin: false, isFirstLogin: userIsFirstLogin)

    sut.login()

    XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<OnboardingView>, "Expect onboarding view visible")
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display onboarding view")
  }

Continue with this requirement for onboarding validation

 ========= LOGIN
     [ ] Validate onboarding set the infos view as root with no animation

As usual write the test first

func test_validateOnboarding_setInfosViewAsRootWithWithAnimation() {
    let userIsFirstLogin = true
    let (sut, navigationSpy) = makeSUT(isLogin: false, isFirstLogin: userIsFirstLogin)

    sut.validateOnboarding() // 1

    XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view visible")
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display infos view")
  }
  1. The validateOnboarding() function not exist we need to create it

Create the validateOnboarding() function

class NavigationService: ObservableObject {
  ...
  func validateOnboarding() {
    setInfosViewAsRoot(animated: true)
  }

  func setInfosViewAsRoot(animated: Bool = false) {
    // Do nothing want to see a failed test first
  }
  ...
}

Run the test and it fails
Let’s make it pass

  func setInfosViewAsRoot(animated: Bool = false) {
    let infosViewController = UIHostingController(rootView: InfosView())
    navigationController.setViewControllers([infosViewController], animated: animated)
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= LOGIN
     [✅] Validate onboarding set the infos view as root with no animation

Continue with this requirement for already login user

 ========= LOGIN
 [] On init user login set infos view as root with no animation

As usual write the test first

  func test_init_userIsLogin_setInfosViewAsRootWithNoAnimation() {
    let userIsLogin = true
    let (_, navigationSpy) = makeSUT(isLogin: userIsLogin, isFirstLogin: true)

    XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view as root view controller")
    XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the infos view on init")
  }

Run the test
He fails with error
XCTAssertTrue failed - Expect infos view as root view controller

Let’s make it pass

class NavigationService: ObservableObject {
  ...
  init(navigationController: UINavigationController, isLogin: Bool, isFirstLogin: Bool) {
    ...
    if isLogin {
      setInfosViewAsRoot()
    } else {
      setLoginViewAsRoot()
    }
  }
  ...

Run tests
They are passing
Can mark this requirement as done.

 ========= LOGIN
 [✅] On init user login set infos view as root with no animation

Continue with this requirement for push to view details

 ========= INFOS
 [] Infos can push to info details view

As usual write the test first

  func test_displayInfoDetails_pushToInfoDetaisViewWithAnimation() {
    let info = Info(title: "A title")
    let (sut, navigationSpy) = makeSUT()

    sut.displayInfoDetails(for: info) // 1

    XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<InfoDetailsView>, "Expect infos details view visible")
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display info details view")
  }
  1. The displayInfoDetails(for:) function not exist we need to create it

Create the displayInfoDetails(for:) for Xcode to compile

  func displayInfoDetails(for info: Info) {
    // Do nothing want to see a failed test first
  }

Run the test and it fails with error
Fatal error: Index out of range
Let’s make it pass

  func displayInfoDetails(for info: Info) {
    let infoDetailsViewController = UIHostingController(rootView: InfoDetailsView(info: info))
    navigationController.pushViewController(infoDetailsViewController, animated: true)
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Infos can push to info details view

Continue with this requirement for infos nav bar title

 ========= INFOS
 [] Infos nav bar have a title

As usual write the test first

  func test_displayInfo_navBarWithTitle() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.setInfosViewAsRoot()

    let controller = try XCTUnwrap(navigationSpy.controllers.first)
    XCTAssertEqual(controller.title, "Infos")
  }

Run the test and it fails with error
XCTAssertEqual failed: ("nil") is not equal to ("Optional("Infos")")

Let’s make it pass

  func setInfosViewAsRoot(animated: Bool = false) {
    let infosViewController = UIHostingController(rootView: InfosView())
    infosViewController.title = "Infos"  👈  
    navigationController.setViewControllers([infosViewController], animated: animated)
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Infos nav bar have a title

Continue with this requirement for infos logout button

 ========= INFOS
   [ ] Infos nav bar have a right button for logout

As usual write the test first

  func test_displayInfo_navBarWithRightButtonLogout() throws {
    let rightBarItemImage = UIImage(systemName: "arrowshape.turn.up.left.circle")
    let (sut, navigationSpy) = makeSUT()

    sut.setInfosViewAsRoot()

    let controller = try XCTUnwrap(navigationSpy.controllers.first, "Expect info view controller")
    XCTAssertNotNil(controller.navigationItem.rightBarButtonItem, "Expect a right bar button item")
    XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.image, rightBarItemImage, "Expect identical image")
    XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.style, .plain, "Expect identical style")
    XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.tintColor, .red, "Expect identical tint color")
  }

Run the test and all assertions failed
Let’s make it pass

  func setInfosViewAsRoot(animated: Bool = false) {
    let infosViewController = UIHostingController(rootView: InfosView())
    infosViewController.title = "Infos"
    let logoutItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left.circle"), style: .plain, target: self, action: nil)  👈
    logoutItem.tintColor = .red 👈
    infosViewController.navigationItem.rightBarButtonItem = logoutItem 👈
    navigationController.setViewControllers([infosViewController], animated: animated)
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Infos nav bar have a right button for logout

Continue with this requirement for logout button that present an alert sheet

 ========= INFOS
   [ ] Infos nav bar right button action present an alert sheet

As usual write the test first

  func test_presentAlertSheetLogout_presentAnAlert() throws {
    let (sut, navigationSpy) = makeSUT()

    let controller = try XCTUnwrap(navigationSpy.controllers.first)
    XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.action?.description, "presentAlertSheetLogout", "Expect an action to present the logout view")

    sut.presentAlertSheetLogout() // 1

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController") // 2
    XCTAssertEqual(navigationSpy.isAnimate, true, "Expect the alert presentation to be animated")
    XCTAssertEqual(alertController.preferredStyle, .actionSheet, "Expect the prefered style to be an .actionSheet")
  }
  1. The presentAlertSheetLogout() function not exist we need to create it
  2. The viewControllerToPresent property not exist we also need to create it

Fix the error for Xcode to compile
Create the presentAlertSheetLogout() function

class NavigationService: ObservableObject {
  ...
  private(set) var alertSheet: UIAlertController?
  ...
  @objc func presentAlertSheetLogout() {
      // Do nothing want to see a failed test first
  }
}

Create the viewControllerToPresent property

  private class UINavigationControllerSpy: UINavigationController {
    ...
    var viewControllerToPresent = UIViewController()
    ...
    override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
      self.viewControllerToPresent = viewControllerToPresent
      self.isAnimate = flag
    }
    ...

Run the test and it fails
XCTAssertEqual failed: ("nil") is not equal to ("Optional("presentAlertSheetLogout")") - Expect an action to present the logout view
XCTUnwrap failed: expected non-nil value of type "UIAlertController" - Expect the view to present is of type UIAlertController

Let’s make it pass
First add a Selector for item button action

  func setInfosViewAsRoot(animated: Bool = false) {
    let infosViewController = UIHostingController(rootView: InfosView())
    infosViewController.title = "Infos"
    👇    
    let logoutItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left.circle"),
                                     style: .plain,
                                     target: self,
                                     action: #selector(presentAlertSheetLogout))
    logoutItem.tintColor = .red
    infosViewController.navigationItem.rightBarButtonItem = logoutItem
    navigationController.setViewControllers([infosViewController], animated: animated)
    updateUserAsNotFirstLogin()

Second create and present the UIAlertController

  @objc func presentAlertSheetLogout() {
    alertSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)

    if let alertSheet = alertSheet {
      navigationController.present(alertSheet, animated: true)
    }
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Infos nav bar right button action present an alert sheet

Continue with this requirement for infos nav bar title

 ========= INFOS
   [ ] Alert sheet have a title (no message)

As usual write the test first

  func test_presentAlertSheetLogout_alertSheetWithTitleAndNoMessage() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.presentAlertSheetLogout()

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
    XCTAssertEqual(alertController.title, "Want to logout?", "Expect a title")
    XCTAssertEqual(alertController.message, nil, "Expect no message")
  }

Run the test and it fails
XCTAssertEqual failed: ("nil") is not equal to ("Optional("Want to logout?")") - Expect a title

Let’s make it pass

  func presentAlertSheetLogout() {
    let title = "Want to logout?" 👈           
    alertSheet = UIAlertController(title: title 👈, message: nil, preferredStyle: .actionSheet)

    if let alertSheet = alertSheet {
      navigationController.present(alertSheet, animated: true)
    }
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Alert sheet have a title (no message)

Continue with this requirement for alert and cancel action

 ========= INFOS
   [ ] Alert sheet have a cancel action (user can dismiss)

As usual write the test first

  func test_presentAlertSheetLogout_alertSheetWithCancelAction() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.presentAlertSheetLogout()

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
    XCTAssertEqual(alertController.actions[0].style, .cancel, "Expect to have a cancel action")
  }

Run the test and it fails
XCTAssertEqual failed: throwing "** -[__NSArray0 objectAtIndex:]: index 0 beyond bounds for empty NSArray" - Expect to have a cancel action*

Let’s make it pass

  func presentAlertSheetLogout() {
    let title = "Want to logout?"
    alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) 
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) 👈
self.logout() })
    alertSheet?.addAction(cancelAction) 👈

    if let alertSheet = alertSheet {
      navigationController.present(alertSheet, animated: true)
    }
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Alert sheet have a cancel action (user can dismiss)

Continue with this requirement for

 ========= INFOS
   [ ] Alert sheet have a logout action

As usual write the test first

  func test_presentAlertSheetLogout_alertSheetWithLogoutAction() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.presentAlertSheetLogout()

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
    XCTAssertEqual(alertController.actions[1].style, .destructive, "Expect to have a destructive action for the logout")
    XCTAssertEqual(alertController.actions[1].title, "Logout", "Expect to have a title")
  }

Run the test and it fails
index 1 beyond bounds [0 .. 0]" - Expect to have a destructive action for the logout
index 1 beyond bounds [0 .. 0]" - Expect to have a title

Let’s make it pass

  func presentAlertSheetLogout() {
    let title = "Want to logout?"
    alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
    let logoutAction = UIAlertAction(title: "Logout", style: .destructive, handler: { _ in }) 👈

    alertSheet?.addAction(cancelAction)
    alertSheet?.addAction(logoutAction) 👈

    if let alertSheet = alertSheet {
      navigationController.present(alertSheet, animated: true)
    }
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= LOGIN
 [✅] Alert sheet have a logout action

Continue with this requirement for logout confirmation

 ========= INFOS
   [ ] Confirm logout set login as root with no animation

As usual write the test first

  func test_presentAlertSheetLogout_confirmLogoutSetLoginAsRootWithNoAnimation() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.presentAlertSheetLogout()

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")

    alertController.actions[1].simulateTriggerAction() // 1

    XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<LoginView>, "Expect login view as root view controller")
    XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the login view")
  }
  1. The UIAlertAction has no member simulateTriggerAction() we need to create it

Fix the error for Xcode to compile

extension UIAlertAction {
  typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
  func simulateTriggerAction() {
    guard let block = value(forKey: "handler") else {
      XCTFail("Expect value for key handler")
      return
    }
    let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
    handler(self)
  }
}

Run the test and it fails
XCTAssertTrue failed - Expect login view as root view controller
XCTAssertFalse failed - Expect no animation when displaying the login view

Let’s make it pass

class NavigationService: ObservableObject {
  ...
  👇
  func logout() { 
    setLoginViewAsRoot()
  }
  ...
  func presentAlertSheetLogout() {
    let title = "Want to logout?"
    alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
    let logoutAction = UIAlertAction(title: "Logout", style: .destructive, handler: { _ in self.logout() 👈 })

    alertSheet?.addAction(cancelAction)
    alertSheet?.addAction(logoutAction)

    if let alertSheet = alertSheet {
      navigationController.present(alertSheet, animated: true)
    }
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Confirm logout set login as root with no animation

Continue with this requirement for logout user that login again display the infos view

 ========= LOGIN
   [ ] Login user after a logout set infos view as root (no onboarding)

As usual write the test first

  func test_login_onLogoutLoginAgainSetInfosAsRootWithAnimation() throws {
    let userIsNotLogin = false
    let userIsFirstLogin = true
    let (sut, navigationSpy) = makeSUT(isLogin: userIsNotLogin,
                                       isFirstLogin: userIsFirstLogin)

    sut.login() // Display onboarding view
    sut.validateOnboarding() // Display infos view
    sut.presentAlertSheetLogout() // Display logout action

    let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")

    alertController.actions[1].simulateTriggerAction() // Logout display login view
    sut.login() // Display infos view (not onboarding)

    XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view visible")
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display infos view")
}

Run the test and it fails
XCTAssertTrue failed - Expect infos view visible
XCTAssertTrue failed - Expect animation when display infos view

Let’s make it pass

class NavigationService: ObservableObject {
  ...
  func login() {
    if isFirstLogin {
      displayOnboardingView()
    } else {
      setInfosViewAsRoot(animated: true) 👈
    }
  }
  ...
  func setInfosViewAsRoot(animated: Bool = false) {
    ...
    updateUserAsNotFirstLogin()
  }
  // MARK: Helpers

  private func updateUserAsNotFirstLogin() {
    isFirstLogin = false
  }
  ...
}

Run tests
They are passing
Can mark this requirement as done.

 ========= INFOS
 [✅] Login user after a logout set infos view as root (no onboarding)

I let you those requirements as an exercise
They have been already made for other view

// ========= INFOS DETAILS
// [] Infos details can push to new info view
// [] Infos details nav bar have a title
// [] Infos details nav bar have a right button for create new info

Continue with this requirement for

 ========= INFOS DETAILS
   [ ] Infos details nav bar have a back button (user can pop back)

As usual write the test first

  func test_displayInfoDetails_navBarWithBackButton() throws {
    let selectedInfo = Info(title: "A title info") // 1
    let (sut, navigationSpy) = makeSUT(isLogin: true, isFirstLogin: false)

    sut.displayInfoDetails(for: selectedInfo)

    let controller = try XCTUnwrap(navigationSpy.controllers[1])
    XCTAssertTrue(controller.navigationItem.hidesBackButton == false, "Expect that nav bar display a back button")
  }
  1. The Info type not exist we need to create it

Fix the error for Xcode to compile

struct Info: Equatable {
    let title: String
}

Run the test and it succeed
Ok cool we didn't change anything
But the test is passing
Why? 🤔
Because a nav bar displays the back button by default
But can we trust a test that we didn't see failing?
Of course no!
To be sure I testing the right behaviour
I always need to see a failing test

Ok let's make this test fails
Hide the back button

func displayInfoDetails(for info: Info) {
    let infoDetailsViewController = UIHostingController(rootView: InfoDetailsView(info: info))
    infoDetailsViewController.title = "Info Details"

    let createItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(displayNewInfo))
    createItem.tintColor = .blue
    infoDetailsViewController.navigationItem.rightBarButtonItem = createItem
    infoDetailsViewController.navigationItem.setHidesBackButton(true 👈,  animated: false)
    navigationController.pushViewController(infoDetailsViewController, animated: true)
  }

Run tests
If now failed
XCTAssertTrue failed - Expect that nav bar display a back button
Perfect that's what we want
I testing the right behaviour
I revert this change and make tests succeed again

Can mark this requirement as done.

 ========= INFOS DETAILS
 [✅] Infos details nav bar have a back button (user can pop back)

I let you those requirements as an exercise
They have been already made for others views

========= NEW INFOS
[ ] New info nav bar have a title
[ ] New info nav bar have a back button (user can pop back)

Continue with this requirement that go back to first view

 ========= NEW INFOS
   [ ] Create new info pop to the root view (infos view)

As usual write the test first

  func test_popToRoot_popBackToRootViewWithAnimation() throws {
    let (sut, navigationSpy) = makeSUT()

    sut.popToRoot() // 1

    XCTAssertEqual(navigationSpy.popToRootViewControllerCallCount, 1, "Expect that pop to root function of the navigation controller was called") // 2
    XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when go back")
  }
  1. The popToRoot() function not exist we need to create it
  2. The popToRootViewControllerCallCount property of the navigationSpy not exist we need to create it

Fix the error for Xcode to compile
Start with popToRoot() function

class NavigationService: ObservableObject {
  ...
  func popToRoot() {
    // Do nothing want to see a failed test first
  }
  ...
}

Now the popToRootViewControllerCallCount property

private class UINavigationControllerSpy: UINavigationController {
    ...
    var popToRootViewControllerCallCount = 0
    ...
    override func popToRootViewController(animated: Bool) -> [UIViewController]? {
      self.popToRootViewControllerCallCount += 1
      self.isAnimate = animated
      return nil
    }
  }

Run the test and it fails
XCTAssertEqual failed: ("0") is not equal to ("1") - Expect that pop to root function of the navigation controller was called
XCTAssertTrue failed - Expect animation when go back

Let’s make it pass

  func popToRoot() {
    navigationController.popToRootViewController(animated: true)  👈
  }

Run tests
They are passing
Can mark this requirement as done.

 ========= NEW INFOS
 [✅] Create new info pop to the root view (infos view)

All behaviour are tested
Cool 😎 !!

 ========= LOGIN
 [✅] On init user not login set login view as root with no animation
   [✅] User first login push to the onboarding view
     [✅] Validate onboarding set the infos view as root with no animation
 [✅] On init user login set infos view as root with no animation

 ========= INFOS
 [✅] Infos can push to info details view
 [✅] Infos nav bar have a title
 [✅] Infos nav bar have a right button for logout
   [✅] Infos nav bar right button action present an alert sheet
     [✅] Alert sheet have a title (no message)
     [✅] Alert sheet have a cancel action (user can dismiss)
     [✅] Alert sheet have a logout action
       [✅] Confirm logout set login as root with no animation
         [✅] Login user after a logout set infos view as root (no onboarding)

 ========= INFOS DETAILS
 [✅] Infos details can push to new info view
 [✅] Infos details nav bar have a title
 [✅] Infos details nav bar have a back button (user can pop back)
 [✅] Infos details nav bar have a right button for create new info

 ========= NEW INFOS
 [✅] New info nav bar have a title
 [✅] New info nav bar have a back button (user can pop back)
 [✅] Create new info pop to the root view (infos view)

Now the integration part
How SwiftUI view will use this UINavigationController from UIKit
Let's see the SwiftUINavigationApp that is the root (entry point of the app)

struct SwiftUINavigationApp: App {
    var body: some Scene {
        WindowGroup {
          LoginView()
        }
    }
}

Only the login view will display if run the app
Without any nav bar
We need a way to inject the UIKit nav bar to a SwiftUI view
This a job for the UIViewControllerRepresentable
That can convert any UIViewController to a SwiftUI view
As UINavigationController is a subclass of UIViewController
We can inject the UINavigationController this way

First create the NavigationControllerSwiftUI

struct NavigationControllerSwiftUI: UIViewControllerRepresentable {
  typealias UIViewControllerType = UINavigationController
  let navigationService: NavigationService

  init(navigationService: NavigationService) {
    self.navigationService = navigationService
  }

  func makeUIViewController(context: Context) -> UIViewControllerType {
    return navigationService.navigationController
  }

  func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}

Next use it in the SwiftUINavigationApp
Of course the logic to init the NavigationService must be tested
And in a real app must call a service to init his parameters
But we are focusing here on the navigation logic
(And this article is already long enough right? 😅)

@main
struct SwiftUINavigationApp: App {
    let navigationService = NavigationService(navigationController: UINavigationController(), isLogin: false, isFirstLogin: true)

    var body: some Scene {
        WindowGroup {
          NavigationControllerSwiftUI(navigationService: navigationService)
            .edgesIgnoringSafeArea(.top)
            .environmentObject(navigationService) // 1
        }
    }
}

Inject the navigationService as environmentObject
So the NavigationService can be accessible from any subview
Here is an example for the LoginView

struct LoginView: View {
  @EnvironmentObject var navigationService: NavigationService // 1

  var body: some View {
    CustomButton(title: "Login") {
      navigationService.login() // 2
    }
      .navigationBarHidden(true) // 3
  }
}
  1. The NavigationService can be access via the @EnvironmentObject variable
  2. Now the navigationService is used on login button action
  3. Hide the nav bar from the SwiftUI view, get crash when try to handle it using UIKit

I make this for all SwiftUI views
Let's see if hour logic cover by test is working
Running the app
Everything working fine
Simulator Screen Recording - iPhone 11 - 2022-06-19 at 22.09.53.gif


This is the end of this article
You see how convenient it is to have the navigation logic in a central place
Easy to scale, to test, to maintain, ...
Hope you enjoy it
Of course you also use the UINavigationBarController only for navigation purposes
And get a custom nav bar made using SwiftUI view

That it !!