# 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1655207076367/7Yoz6xgR0.png align="center")

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:  
<pre>
 ========= 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)
</pre>

---
Let's start with the first requirement for login  
<pre>
 ========= LOGIN
 [ ] On init user not login set login view as root with no animation
</pre>

Start writing the test   
```swift
  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  
```swift 
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`  
```swift 
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  
```swift
  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  
<pre>
 ========= LOGIN
 [✅] On init user not login set login view as root with no animation
</pre>

---
Continue with the second requirement for login  
<pre>
 ========= LOGIN
   [ ] User first login push to the onboarding view
</pre>

As usual writing the test first  
```swift
  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  

```swift
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`  
```swift 
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  
```swift 
  private func displayOnboardingView() {
    let onboardingViewController = UIHostingController(rootView: OnboardingView())
    navigationController.pushViewController(onboardingViewController, animated: true)
  }
``` 

Run the test it pass   
Can mark this spec as done  
<pre>
 ========= LOGIN
 [✅] User first login push to the onboarding view
</pre>

---
To simply the test setup   
Extract the sut creation to an helper method  
```swift
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
```swift 
  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  
<pre>
 ========= LOGIN
     [ ] Validate onboarding set the infos view as root with no animation
</pre>

As usual write the test first  
```swift
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
```swift
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  
```swift 
  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.  
<pre>
 ========= LOGIN
     [✅] Validate onboarding set the infos view as root with no animation
</pre>

---
Continue with this requirement for already login user
<pre>
 ========= LOGIN
 [] On init user login set infos view as root with no animation
</pre>

As usual write the test first  
```swift
  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  
```swift 
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.  
<pre>
 ========= LOGIN
 [✅] On init user login set infos view as root with no animation
</pre>

---
Continue with this requirement for push to view details
<pre>
 ========= INFOS
 [] Infos can push to info details view
</pre>

As usual write the test first  
```swift
  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    
```swift
  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  
```swift 
  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.  
<pre>
 ========= INFOS
 [✅] Infos can push to info details view
</pre>

---
Continue with this requirement for infos nav bar title 
<pre>
 ========= INFOS
 [] Infos nav bar have a title
</pre>

As usual write the test first  
```swift
  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  
```swift 
  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.  
<pre>
 ========= INFOS
 [✅] Infos nav bar have a title
</pre>

---
Continue with this requirement for infos logout button   
<pre>
 ========= INFOS
   [ ] Infos nav bar have a right button for logout
</pre>

As usual write the test first  
```swift
  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  
```swift 
  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.  
<pre>
 ========= INFOS
 [✅] Infos nav bar have a right button for logout
</pre>

---
Continue with this requirement for logout button that present an alert sheet  
<pre>
 ========= INFOS
   [ ] Infos nav bar right button action present an alert sheet
</pre>

As usual write the test first  
```swift
  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  
```swift
class NavigationService: ObservableObject {
  ...
  private(set) var alertSheet: UIAlertController?
  ...
  @objc func presentAlertSheetLogout() {
      // Do nothing want to see a failed test first
  }
}
```

Create the `viewControllerToPresent` property
```swift
  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  
```swift
  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`  
```swift 
  @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.  
<pre>
 ========= INFOS
 [✅] Infos nav bar right button action present an alert sheet
</pre>

---
Continue with this requirement for infos nav bar title 
<pre>
 ========= INFOS
   [ ] Alert sheet have a title (no message)
</pre>

As usual write the test first  
```swift
  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  
```swift 
  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.  
<pre>
 ========= INFOS
 [✅] Alert sheet have a title (no message)
</pre>

---
Continue with this requirement for alert and cancel action
<pre>
 ========= INFOS
   [ ] Alert sheet have a cancel action (user can dismiss)
</pre>

As usual write the test first  
```swift
  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  
```swift 
  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.  
<pre>
 ========= INFOS
 [✅] Alert sheet have a cancel action (user can dismiss)
</pre>

---
Continue with this requirement for  
<pre>
 ========= INFOS
   [ ] Alert sheet have a logout action
</pre>

As usual write the test first  
```swift
  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  
```swift 
  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.  
<pre>
 ========= LOGIN
 [✅] Alert sheet have a logout action
</pre>

---
Continue with this requirement for logout confirmation  
<pre>
 ========= INFOS
   [ ] Confirm logout set login as root with no animation
</pre>

As usual write the test first  
```swift
  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    
```swift
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  
```swift 
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.  
<pre>
 ========= INFOS
 [✅] Confirm logout set login as root with no animation
</pre>

---
Continue with this requirement for logout user that login again display the infos view
<pre>
 ========= LOGIN
   [ ] Login user after a logout set infos view as root (no onboarding)
</pre>

As usual write the test first  
```swift
  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  
```swift 
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.  
<pre>
 ========= INFOS
 [✅] Login user after a logout set infos view as root (no onboarding)
</pre>

---
I let you those requirements as an exercise   
They have been already made for other view  
<pre>
// ========= 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
</pre>

---
Continue with this requirement for  
<pre>
 ========= INFOS DETAILS
   [ ] Infos details nav bar have a back button (user can pop back)
</pre>

As usual write the test first  
```swift
  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    
```swift
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  
```swift
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.  
<pre>
 ========= INFOS DETAILS
 [✅] Infos details nav bar have a back button (user can pop back)
</pre>

---
I let you those requirements as an exercise   
They have been already made for others views  
<pre>
========= NEW INFOS
[ ] New info nav bar have a title
[ ] New info nav bar have a back button (user can pop back)
</pre>

---
Continue with this requirement that go back to first view  
<pre>
 ========= NEW INFOS
   [ ] Create new info pop to the root view (infos view)
</pre>

As usual write the test first  
```swift
  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
```swift
class NavigationService: ObservableObject {
  ...
  func popToRoot() {
    // Do nothing want to see a failed test first
  }
  ...
}
```

Now the `popToRootViewControllerCallCount` property
```swift
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  
```swift 
  func popToRoot() {
    navigationController.popToRootViewController(animated: true)  👈
  }
``` 

Run tests  
They are passing  
Can mark this requirement as done.  
<pre>
 ========= NEW INFOS
 [✅] Create new info pop to the root view (infos view)
</pre>

--- 
All behaviour are tested   
Cool 😎 !!  
<pre>
 ========= 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)
</pre>

---
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)  
```swift
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`
```swift
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? 😅)  
```swift
@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`
```swift
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1655670778205/xmReIw6tN.gif align="center")

---
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 !!
