Home iOS & Swift Books iOS Test-Driven Development by Tutorials

13
Breaking Up Dependencies Written by Michael Katz

It’s always safer to make a change when you have tests in place already. In the absence of existing tests, however, you may need to make changes just to add tests! One of the most common reasons for this is tightly-coupled dependencies: You can’t add tests to a class because it depends on other classes that depend on other classes… View controllers especially are often victims of this issue.

By creating a dependency map in the last chapter, you were able to find where you want to make changes and, in turn, where you really need to have tests.

This chapter will teach you how to break dependencies safely to add tests around where you want to change.

Getting started

As a reminder, in this chapter, you will build upon and improve the MyBiz app. The powers that be want to build a separate expense reporting app. In the interest of DRY (Don’t Repeat Yourself) they want to reuse the login view from your app in the new app. The best way to do that is to pull the login functionality into its own framework so it can be reused across projects.

The login view controller is the obvious place to start because it presents the login UI and uses all of the other code related to login. In the previous chapter, you built out a dependency map for the login view controller and identified some change points. You’ll use that map as a guide to break up the dependencies so login can stand alone.

Characterizing the system

Before moving any code, you want to make sure that the refactors won’t disturb the behavior of the app. To do that, start with a characterization test for the signIn(_:) function of LoginViewController. This is the main entry point for signing into the app and it’s crucial that it continues to work.

import XCTest
@testable import MyBiz

class LoginViewControllerTests: XCTestCase {

  var sut: LoginViewController!

  // 1
  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "login")
      as? LoginViewController
    UIApplication.appDelegate.userId = nil

    sut.loadViewIfNeeded()
  }

  // 2
  override func tearDown() {
    sut = nil
    UIApplication.appDelegate.userId = nil //do the "logout"
    super.tearDown()
  }

  func testSignIn_WithGoodCredentials_doesLogin() {
    // given
    sut.emailField.text = "agent@shield.org"
    sut.passwordField.text = "hailHydra"

    // when
    // 3
    let exp = expectation(for: NSPredicate(block:
    { vc, _ -> Bool in
      return UIApplication.appDelegate.userId != nil
    }), evaluatedWith: sut, handler: nil)

    sut.signIn(sut.signInButton!)

    // then
    // 4
    wait(for: [exp], timeout: 1)
    XCTAssertNotNil(UIApplication.appDelegate.userId,
                    "a successful login sets valid user id")
  }
}
func testSignIn_WithBadCredentials_showsError() {
  // given
  sut.emailField.text = "bad@credentials.ca"
  sut.passwordField.text = "Shazam!"

  // when
  let exp = expectation(for: NSPredicate(block:
  { vc, _ -> Bool in
    return UIApplication.appDelegate.window?.rootViewController?
      .presentedViewController != nil
  }), evaluatedWith: sut, handler: nil)

  sut.signIn(sut.signInButton!)

  // then
  wait(for: [exp], timeout: 1)
  let presentedController = UIApplication.appDelegate.window?
    .rootViewController?.presentedViewController
    as? ErrorViewController
    XCTAssertNotNil(presentedController,
                    "should be showing an error controller")
    XCTAssertEqual(presentedController?.alertTitle,
                   "Login Failed")
    XCTAssertEqual(presentedController?.subtitle,
                   "User has not been authenticated.")
}

Breaking up the API/AppDelegate dependency

Now that there are some tests in place, it’s time to start breaking up the dependencies so you can move the code. Starting with the API <-> AppDelegate interdependency will make it easier to break up those classes from LoginViewController later.

init(server: String) {
  self.server = server
  session = URLSession(configuration: .default)
}
let server: String
api = API(server: AppDelegate.configuration.server)
init() {
  super.init(server: "http://mockserver")
}

Using a notification for communication

The next step is to fix the logout() dependency. This method calls back to app delegate, but handling the post-logout state shouldn’t really live with an app delegate. You’ll use a Notification to pass the event in a general way. You won’t fix AppDelegate this time around, but you will make API ignorant of which class cares about it.

let UserLoggedOutNotification =
  Notification.Name("user logged out")
import XCTest
@testable import MyBiz

class APITests: XCTestCase {

  var sut: API!

  // 1
  override func setUp() {
    super.setUp()
    sut = MockAPI()
  }

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  // 2
  func givenLoggedIn() {
    sut.token = Token(token: "Nobody", userID: UUID())
  }

  // 3
  func testAPI_whenLogout_generatesANotification() {
    // given
    givenLoggedIn()
    let exp = expectation(forNotification:
      UserLoggedOutNotification, object: nil)

    // when
    sut.logout()

    // then
    wait(for: [exp], timeout: 1)
    XCTAssertNil(sut.token)
  }
}
func logout() {
  token = nil
  delegate = nil
  let note = Notification(name: UserLoggedOutNotification)
  NotificationCenter.default.post(note)
}
func setupListeners() {
  NotificationCenter.default
    .addObserver(forName: UserLoggedOutNotification,
                 object: nil,
                 queue: .main) { _ in
    self.showLogin()
  }
}
setupListeners()

Reflecting on the breakup

This exercise illustrated two ways for detangling two objects:

Breaking the AppDelegate dependency

The next stop on the dependency-detangling train is removing AppDelegate from LoginViewController.

Injecting the API

In LoginViewController.swift, change the api variable to:

var api: API!
let loginViewController = window?.rootViewController as? LoginViewController
loginViewController?.api = api
loginController?.api = api
sut.api = UIApplication.appDelegate.api

Detangling login success

If you look at loginSucceeded(userId:) on the LoginViewController, you’ll see that none of its contents really belong in the view controller — all of the work happens on the AppDelegate! The issue then becomes how to indirectly link the API action to a consequence in the AppDelegate. Well… last time you used a Notification and you can do so again.

let UserLoggedInNotification =
  Notification.Name("user logged in")
enum UserNotificationKey: String {
  case userId
}
func testAPI_whenLogin_generatesANotification() {
  // given
  var userInfo: [AnyHashable: Any]?
  let exp = expectation(
    forNotification: UserLoggedInNotification,
    object: nil) { note in
      userInfo = note.userInfo
      return true
  }

  // when
  sut.login(username: "test", password: "test")

  // then
  wait(for: [exp], timeout: 1)
  let userId = userInfo?[UserNotificationKey.userId]
  XCTAssertNotNil(userId,
    "the login notification should also have a user id")
}
let note = Notification(name: UserLoggedInNotification,
                        object: self,
                        userInfo: [UserNotificationKey.userId:
                          token.userID.uuidString])
NotificationCenter.default.post(note)
override func login(username: String, password: String) {
  let token = Token(token: username, userID: UUID())
  handleToken(token: token)
}
func handleLogin(userId: String) {
  self.userId = userId

  let storyboard = UIStoryboard(name: "Main", bundle: nil)
  let tabController =
    storyboard.instantiateViewController(
      withIdentifier: "tabController")
  window?.rootViewController = tabController
}
NotificationCenter.default
  .addObserver(
    forName: UserLoggedInNotification,
    object: nil,
    queue: .main) { note in
      if let userId =
        note.userInfo?[UserNotificationKey.userId] as? String {
          self.handleLogin(userId: userId)
      }
}

Breaking the ErrorViewController dependency

Looking at the dependency map for red lines, it next makes sense to tackle the dependency on LoginViewController from ErrorViewController.

import XCTest
@testable import MyBiz

class ErrorViewControllerTests: XCTestCase {

  var sut: ErrorViewController!

  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "error")
      as? ErrorViewController
  }

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  func whenDefault() {
    sut.type = .general
    sut.loadViewIfNeeded()
  }

  func whenSetToLogin() {
    sut.type = .login
    sut.loadViewIfNeeded()
  }

  func testViewController_whenSetToLogin_primaryButtonIsOK() {
    // when
    whenSetToLogin()

    // then
    XCTAssertEqual(sut.okButton.currentTitle, "OK")
  }

  func testViewController_whenSetToLogin_showsTryAgainButton() {
    // when
    whenSetToLogin()

    // then
    XCTAssertFalse(sut.secondaryButton.isHidden)
    XCTAssertEqual(sut.secondaryButton.currentTitle,
      "Try Again")
  }

  func testViewController_whenDefault_secondaryButtonIsHidden() {
    // when
    whenDefault()

    // then
    XCTAssertNil(sut.secondaryButton.superview)
  }
}

Removing login from error handling

Now that you’ve got the base behavior covered, you’re ready to go ahead and start breaking out the dependency. ErrorViewController has a try again functionality that calls back into the LoginViewController. This not only violates SOLID principles but it’s cumbersome to add this try again functionality to other screens since you’ll need to add to several switch statements and further tie in dependencies.

struct SecondaryAction {
  let title: String
  let action: () -> ()
}
import XCTest
@testable import MyBiz

import XCTest

class ErrorViewControllerTests: XCTestCase {

  var sut: ErrorViewController!

  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "error")
      as? ErrorViewController
  }

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  func testSecondaryButton_whenActionSet_hasCorrectTitle() {
    // given
    let action = ErrorViewController.SecondaryAction(
                   title: "title") {}
    sut.secondaryAction = action

    // when
    sut.loadViewIfNeeded()

    // then
    XCTAssertEqual(sut.secondaryButton.currentTitle, "title")
  }

  func testSecondaryAction_whenButtonTapped_isInvoked() {
    // given
    let exp = expectation(description: "secondary action")
    var actionHappened = false
    let action = ErrorViewController.SecondaryAction(
                   title: "action") {
      actionHappened = true
      exp.fulfill()
    }
    sut.secondaryAction = action
    sut.loadViewIfNeeded()

    // when
    sut.secondaryAction(())

    // then
    wait(for: [exp], timeout: 1)
    XCTAssertTrue(actionHappened)
  }
}
var secondaryAction: SecondaryAction? = nil
private func updateAction() {
  guard let action = secondaryAction else {
    secondaryButton.removeFromSuperview()
    return
  }
  secondaryButton.setTitle(action.title, for: .normal)
}
updateAction()
if let action = secondaryAction {
  dismiss(animated: true)
  action.action()
} else {
  Logger.logFatal("no action defined.")
}
func showAlert(title: String,
       subtitle: String?,
       action: ErrorViewController.SecondaryAction? = nil,
       skin: Skin? = nil) {
alertController.type = type
alertController.secondaryAction = action
func loginFailed(error: Error) {
  let retryAction = ErrorViewController.SecondaryAction(
                      title: "Try Again") { [weak self] in
    if let self = self {
      self.signIn(self)
    }
  }
  showAlert(title: "Login Failed",
            subtitle: error.localizedDescription,
            action: retryAction,
            skin: .loginAlert)
}
sut.secondaryAction = .init(title: "Try Again", action: {})

Challenge

This chapter’s challenge is a simple one. You may have noticed that input validation was left out of the LoginViewControllerTests characterization tests. Your challenge is to add them now, so you will have a more robust test suite before moving the code into its own module in the next chapter. For an additional challenge, add unit tests for the Validators functions in MyBizTests.

Key Points

  • Dependency Maps are your guide to breaking dependencies.
  • Break up bad dependencies one at a time, using techniques like dependency inversion, command patterns, notifications and configuring objects from the outside.
  • Write tests before, during and after a large refactor.

Where to go from here?

Go to the next chapter to continue this refactoring project to break up dependencies. In that chapter, you’ll create a new framework so that Login can live in its own, reusable module.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.