Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

13. Breaking Up Dependencies
Written by Michael Katz

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

Skin UIViewController +Alert Error ViewController AppDelegate Models Event Employee Announcement Product PurchaseOrder Configuration APIDelegate Token Validators Login ViewController API

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 setUpWithError() throws {
    try super.setUpWithError()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "login")
      as? LoginViewController
    UIApplication.appDelegate.userId = nil

    sut.loadViewIfNeeded()
  }

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

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

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

    sut.signIn(sut.signInButton!)

    // then
    // 4
    wait(for: [exp], timeout: 5)
    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 predicate = NSPredicate { _, _ -> Bool in
    UIApplication.appDelegate
                 .rootController?.presentedViewController != nil
  }
  let exp = expectation(
    for: predicate,
    evaluatedWith: sut,
    handler: nil)

  sut.signIn(sut.signInButton!)

  // then
  wait(for: [exp], timeout: 5)
  let presentedController = 
    UIApplication.appDelegate
                 .rootController?
                 .presentedViewController
                 as? ErrorViewController
  XCTAssertNotNil(
    presentedController,
    "should be showing an error controller")
  XCTAssertEqual(
    presentedController?.alertTitle,
    "Login Failed")
  XCTAssertEqual(
    presentedController?.subtitle,
    "Unauthorized")
}

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 setUpWithError() throws {
    try super.setUpWithError()
    sut = MockAPI()
  }

  override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
  }

  // 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:

Mher UUHeanVazlnefpun +Aduvf Udros RiobFodploqgev EknKaqagefi Lalokp Uhuln Iybvukoa Ivraiwgivehr Cnelixl XiwdjamiUhmug Yepfebuvoleaw OGOHubazuri Jemiq Jiquhukujc Kogic SoedGuvqmimcit UVO

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!
func scene(
  _ scene: UIScene,
  willConnectTo session: UISceneSession,
  options connectionOptions: UIScene.ConnectionOptions) {

  let loginViewController = 
    UIApplication.appDelegate.rootController as? LoginViewController
  loginViewController?.api = UIApplication.appDelegate.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.user.id.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")
  rootController = 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)
      }
  }
Vpip AIJiabXelpzunroj +Exewb Omfet CiuwLivkfejwor ApbMotehoyi Noretg Ozimv Ebhbojiu Ultuesnavorq Nhowuzg NuwfguteIkluz Koxtodejigeew EXEYapusapu Xohaf Ridiqucacl Pasuw HiokBigljohmoh AVI

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 setUpWithError() throws {
    try super.setUpWithError()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "error")
      as? ErrorViewController
  }

  override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
  }

  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” function 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: () -> Void
}
import XCTest
@testable import MyBiz

class ErrorViewControllerTests: XCTestCase {
  var sut: ErrorViewController!

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

  override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
  }

  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?
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") {}
Dkiz EUZeizRudtguhgek +Iyotf Avgut GiofXijsgohcuk UgySugekayo Pinozd Ewogp Ojcdizea Ajhiizpowulw Vginetp WohybeboOfgut Yehbujedeliiv ADOJejeduqo Nemap Pezamagerw Kipos WoowVejcpohpal IPI

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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now