Test-Driven Development: #6 Testing Alerts, Popups, and Messages

Test-Driven Development: #6 Testing Alerts, Popups, and Messages

Now how do we test those pesky alerts?

Featured on Hashnode

Introduction

It's been a long time! Really happy that I have time to write about TDD again, and with an interesting topic from which we can know if our users had seen an alert, popups, or simply seen a message (toast) appearing to him

The Point?

How is this important, you ask?

Well, Imagine that our user is trying to log in but he is giving us an invalid email without him noticing, it would be nice to either show a message to him, saying that he missed a '@' in the email field

But what if after a couple of iterations over our app, and somewhere in version '1.4.3', this broke, and users no longer get a hint about why their email is not getting accepted, especially for an old user of our app, it might get irritating

However, if a test covers this; before we even launch any new version, this issue would be well on our issue tracker.

So this post we will cover how to cover such cases with a unit test coverage, and I think it would be interesting in showing how do I handle issues in my project in another post

Without further ado, let's get rolling 😎

Dr. Eggman getting ready

Revisiting the project after some changes

Remember that last demo video I put on the previous post of this series?

Well, I did promise you guys that I will make improvements to the refactoring and I did,

It now utilizes my open-source OrdiKit which is a fast way for me to put files I use from time to time, or just wanna test an idea or probably launch a project, it utilizes SwiftUI and UIKit

Showing Ordikit in the project

I don't want to spend much time on what went on in the Refactoring, but there is an interesting part I believe, which is called ServiceLocator

It's a pattern from Game Development and is a bit strange in iOS development as it uses Singletons

Before you throw any knives in the comments, let's take a step back and see why is this put here

If we think about Network and Cache, normally these are 2 dependencies where the app depends on

They shouldn't be repeated or recreated, in fact, that would create a problem if am tracking the progress of some network calls, or am storing 2 stuff simultaneously on the disk

So it makes sense to have this as a single instance inside the ServiceLocator.

Other than that, we're ready to go test our side-effects in form of alerts

If we return to our Testing file TDDTests/Login/LoginTests

We can add a test function like so…

func test_GivenInvalidEmail_WhenLogin_UserSeesAnAlert() {
    // Given
    let email = ""
    let password = "Somevalid1Password"

    // When
    sut.login(email: email, password: password)

    // Then
    XCTAssertEqual(
      router.routingAction,
      RoutingAction.alert(ValidationError.empty(.email).errorDescription)
    )
  }

You will find that we want to have a new dependency called router, ideally the router would have its own state or action, so we can check if that action corresponds to what we want to verify, in our case, when the user forgets to pass his email, in such case we would want to show an alert (just for sake of simplicity for now), that he can’t leave his email blank

As of now, we don’t have such functionality so let’s start adding it to our production code

public typealias AlertAction = (title: String, style: UIAlertAction.Style, action: () -> Void)

public protocol RouterProtocol: AnyObject {
  func present(_ view: UIViewController)
  func startActivityIndicator()
  func stopActivityIndicator()
  func dismiss()
  func pop(animated: Bool)
  func popToRoot()
  func popTo(vc: UIViewController)
  func push(_ view: UIViewController)
  func alertWithAction(
    title: String?,
    message: String?,
    alertStyle: UIAlertController.Style,
    actions: [AlertAction]
  )
}

This is how our Routing functionality is going to provide, now let’s wait here and think what is going on

What we want, is an encapsulated access to our ViewController from the presentation layer, in our case, the LoginViewModel this encapsulation provides us with scoping to just the navigation functionality, so I can control the ViewController’s navigation from here, without having any access on the ViewController itself, let’s see how with extra details, by defining our Router Implementor

public final class Router: RouterProtocol {
  private weak var presentedView: UIViewController!

  init(_ view: UIViewController) {
    presentedView = view
  }

  public func present(_ view: UIViewController) {
    presentedView?.present(view, animated: true, completion: nil)
  }

  public func dismiss() {
    presentedView?.dismiss(animated: true, completion: nil)
  }

  public func pop(animated: Bool) {
    _ = presentedView?.navigationController?.popViewController(animated: animated)
  }

  public func popToRoot() {
    _ = presentedView?.navigationController?.popToRootViewController(animated: true)
  }

  public func popTo(vc: UIViewController) {
    _ = presentedView?.navigationController?.popToViewController(vc, animated: true)
  }

  public func push(_ view: UIViewController) {
    presentedView?
      .navigationController?
      .pushViewController(view, animated: true)
  }

  public func alertWithAction(
    title: String?,
    message: String?,
    alertStyle: UIAlertController.Style,
    actions: [AlertAction]
  ) {
    let alert = UIAlertController(title: title, message: message, preferredStyle: alertStyle)
    actions.map { action in
      UIAlertAction(title: action.title, style: action.style, handler: { (_) in
        action.action()
      })
    }.forEach {
      alert.addAction($0)
    }

    presentedView?.present(alert, animated: true)
  }
}

Notice the private access modifier before the controller? Exactly what we’re looking for

Giving our ViewModel The Power of Navigation

If we get back to our ViewModel, we can now hand it a router by dependency injection, and have it show the view an alert when needed


...
  var cancellables = Set<AnyCancellable>()
  var router: RouterProtocol

  init(router: RouterProtocol) {
    self.router = router
  }
...
func validate(_ email: String, _ password: String) {
    ...
    guard !errors.isEmpty else { return }
    state.send(.failure(errors))
    router.alertWithAction(
      title: "Validtion Errors",
      message: errors.map { $0.text }.joined(separator: "\n"),
      alertStyle: .actionSheet,
      actions: [
        (title: "Ok", style: .default, action: { })
      ]
    )
  }

If we ran and tested our code…

image.png

Voilà

Faking our Router until we make it

Now comes our testing code’s turn

First, we need to create a FakeRouter that gives us information about the navigation state In order for us to have access to our navigation stack, we need to define a state enum, which holds the actions given to the router

enum RoutingAction: Equatable {
  case present(_ vc: UIViewController.Type)
  case push(_ vc: UIViewController.Type)
  case dismiss
  case pop
  case popToRoot
  case popToVC
  case popTo(_ vc: UIViewController.Type)
  case alertWithAction((title: String, message: String))

  static public func ==(lhs: RoutingAction, rhs: RoutingAction) -> Bool {
    switch (lhs, rhs) {
      case let (.popTo(a), .popTo(b)): return a == b
      case let (.present(a), .present(b)): return a == b
      case let (.push(a), .push(b)): return a == b
      case let (.alertWithAction(a), .alertWithAction(b)):
        return a.title == b.title && a.message == b.message
      case (.dismiss, .dismiss),
        (.pop, .pop),
        (.popToRoot, .popToRoot),
        (.popToVC, .popToVC):
        return true
      default:
        return false
    }
  }
}

This way, we can have an in-memory storage that we can use it later on, to test against whether the user did present a view, did push to a view, or simply gave an alert

And to wrap it up so we can continue on with testing…

final class FakeRouter: RouterProtocol {
  var actions: [RoutingAction] = []
  var alertActions: [AlertAction] = []

  func popToRoot() {
    actions.append(.popToRoot)
  }

  func popTo(vc: UIViewController) {
    actions.append(.popToVC)
  }

  func push(_ view: UIViewController) {
    actions.append(.push(type(of: view)))
  }

  func present(_ view: UIViewController) {
    actions.append(.present(type(of: view)))
  }

  func dismiss() {
    self.actions.append(.dismiss)
  }

  func alertWithAction(
    title: String?,
    message: String?,
    alertStyle: UIAlertController.Style,
    actions: [AlertAction]
  ) {
    self.actions.append(.alertWithAction((title ?? "", message ?? "")))
    self.alertActions.append(contentsOf: actions)
  }

  func pop(animated: Bool) {
    self.actions.append(.pop)
  }
}

Notice how we append the type to the actions array?, similar to our FakeCache, we just register it until the test has run, so we can later on test against if it indeed has a certain action that we expect it to be, or not.

Back to our tests, now we need to supply our SUT with a FakeRouter so we can test it

Wrapping it up

class LoginViewModelTests: XCTestCase {

  var network: FakeNetworkManager!
  var cache: FakeCache!
  var router: FakeRouter! // <- Changed
  var sut: LoginViewModel!

  override func setUp() {
    super.setUp()
    network = .init()
    cache = .init()
    router = .init() // <- Changed

    ServiceLocator.main = .init(network: network, cache: cache)

    sut = .init(router: router) // <- Changed
  }

  override func tearDown() {
    super.tearDown()
    network = nil
    cache = nil
    router = nil // <- Changed
    sut = nil
  }

Now that our LoginViewModel knows how to use our FakeRouter, let’s fix some of our test code below

func test_GivenInvalidEmail_WhenLogin_UserSeesAnAlert() {
    // Given
    let email = ""
    let password = "Somevalid1Password"

    // When
    sut.login(email: email, password: password)

    // Then
    XCTAssertEqual(
      router.actions,
      [
        RoutingAction.alertWithAction((
          title: "Validation Errors",
          message: "Email can't be empty"
        ))
      ]
    )
  }

And that’s it!

Michael from The Office Saluting you

Conclusion

Now that we have a way of testing our Alerts, Popups, and Messages… or simply what I like to call them, Async Views, where a view cuts through the flow for the user to take an action then return to the normal flow We can now go to our next stop which is testing the our navigation flow, which by now you can guess how it really can be easy

Tell you what, how about you try to implement this on your own? You will be amazed by how easy it is now to test your navigation, and next stop we will go over it quickly and talk about something really important, which by now, the ViewModel became somewhat big, so let’s talk about how we can dissect it even further, so we can avoid going from ‘MVC’ to ‘MVVM’ (aka, massive view controller, to massive view model) 😉

And if you’re an eager one and wants to know our progress on Github, feel free to see the code here