Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

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.

Add a new Unit Test Case Class file in CharacterizationTests named LoginViewControllerTests.swift.

Replace the contents of the file with the following:

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")
  }
}

This code handles the basic sign-in scenario in the following ways:

  1. In setUp creates the sut from the main storyboard and loads it. It also clears the shared userId from AppDelegate. It is a proxy for the “being logged in” state. Since the app delegate is persisted across tests, it’s important to clear it out so each test starts off as not logged in.
  2. In tearDown, clearing that userId state is important in case there are other tests that don’t clear it in their setUp.
  3. In the test itself, this predicate expectation waits for the userId state to be set in order to fulfill the expectation. This way, the test knows it is safe to proceed.
  4. The test waits for the userId to be set and then asserts that it is not nil. Even though the expectation will also time out for the same condition, it’s always good to have an explicit assert — rather than using the timeout to catch the error.

Remember to start the backend before running this test — or it will fail! This test requires live responses. You have not broken its dependency on the real backend implementation yet. For instructions on setting up and starting the MyBiz backend, see Chapter 13, Legacy Problems.

Build and test — and the test passes! This example short-circuits the discovery part of characterization tests, as described in Chapter 12, Legacy Problems. It’s an important part of the process but out of the scope of this chapter.

Next, capture the main error case in a test. This flow where an invalid login response is shown to the user is an important function of the view controller. This also helps cover detangling the ErrorViewController later.

Add the following test:

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.")
}
  • The given section sets up invalid credentials.
  • The when section creates an expectation that waits for a modal view to be shown, supposedly the error view.
  • The then section, after waiting for the expectation, checks that the modal is an ErrorViewController and that the alertTitle and subtitle match the expected response for bad credentials.

These conditions are great because they test for a specific error, rather than a broken network connection. However, this test is then quite brittle and dependent on server-side text.

Build and test. Yet again this will pass and you’ve covered the two main (existing) flows through this view controller. As a challenge, write tests for the validator conditions as well (bad email and password).

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.

You can use Swift’s strict type system to make it easier when removing dependencies. For example, go to API.swift and search the file for uses of AppDelegate.

The first usage of AppDelegate creates the server constant. This one is quite simple to deal with. You’ll just move it from being set automatically to an init parameter.

Replace the init method with:

init(server: String) {
  self.server = server
  session = URLSession(configuration: .default)
}

Then, update the line for let server = with the following:

let server: String

If you build the app, the compiler will tell you what needs to happen next. Go to AppDelegate.swift and replace the instantiation of API with:

api = API(server: AppDelegate.configuration.server)

Going back to the tests, in MockAPI.swift add this init method:

init() {
  super.init(server: "http://mockserver")
}

Build and test and the tests will still pass. This was a simple move so it doesn’t need any additional testing beyond the tests already in place.

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.

At the top of API.swift, right after the import statement, add:

let UserLoggedOutNotification =
  Notification.Name("user logged out")

This creates a new notification that informs the rest of the app that the user logged out.

Before proceeding, it’s time to create some tests! Create a New Unit Test Class named APITests in the MyBizTests target.

Replace the contents of the file with the following:

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)
  }
}
  1. This test sets up an API as the system-under-test. It’s okay that it’s a MockAPI since the methods under test are inherited from API. This short-term compromise is okay because its modified behavior is not part of the work of breaking out LoginViewController.
  2. There’s one helper method givenLoggedIn() that sets a fake token to simulate the “logged in” state for the SUT.
  3. The test itself is pretty simple, calling logout() and waiting for the UserLoggedOutNotification. The test also asserts that the token was reset to nil.

Run the tests. You’ll see that this new test does not yet pass. To get the test to pass, open API.swift and replace the entire logout() method with:

func logout() {
  token = nil
  delegate = nil
  let note = Notification(name: UserLoggedOutNotification)
  NotificationCenter.default.post(note)
}

Instead of calling back directly into AppDelegate, this calls that code indirectly through the notification center. Now, API no longer has any direct dependency on the delegate. However, to keep the app running, you need to make the following changes in AppDelegate.swift.

The following method:

func setupListeners() {
  NotificationCenter.default
    .addObserver(forName: UserLoggedOutNotification,
                 object: nil,
                 queue: .main) { _ in
    self.showLogin()
  }
}

This adds a listener for the new notification. Then, in application(_:didFinishLaunchingWithOptions:) call it before the return statement by adding the following line of code:

setupListeners()

Build and test again. You can also build and run and then can go through a full login/logout cycle to see that everything still works.

Reflecting on the breakup

This exercise illustrated two ways for detangling two objects:

  1. Configuring the object at instantiation. API now has its server URL set at init time rather than calling into a singleton later.
  2. Replacing direct calls with events. Logout events are propagated through a Notification instead of a hard-coded callback.

In logout(), the call to AppDelegate was replaced by posting a Notification. As an iOS developer, you have many options for sending asynchronous events. NotificationCenter is the simplest since it comes with Foundation. You could also send a signal using RxSwift or Combine, a custom event bus or manage a list of custom delegates.

A further refactor to divide up responsibilities would be to extract user state management from API. This would allow you to keep API as a stateless gateway to the backend and the user state manager would be able to sit in between the UI and the login/logout.

The other technique used here was to pass in the configuration to the init method. Here, all that was needed was the base URL for the server and there was no functional reason to reach back to the AppDelegate. In fact, API no longer relies on any UI code: You can go ahead and remove the import UIKit line from the top of the file. Now, API can be used in all sorts of other apps that are built upon the same API!

You can update the dependency map with a little white-out to reflect API’s newfound freedom from the AppDelegate.

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!

Now, the api can be set externally to the class instead of depending directly on AppDelegate.

Note: For most classes, using let and injecting the value through an init is the way to go. For view controllers, the injection will have to be done after instantiation, usually in a prepare(for:sender:) with a segue or just before presentation when done through code, as you’ll see below.

To make the app still work, you have to set the api variable in a few places. In AppDelegate.swift, add the following to application(_:didFinishLaunchingWithOptions:):

let loginViewController = window?.rootViewController as? LoginViewController
loginViewController?.api = api

This sets that api when the app is first loaded. Next, change showLogin() by adding the following line immediately before setting the rootViewController:

loginController?.api = api

Finally, since there was already a test to cover the view controller, you’ll need to update the test class. In LoginViewControllerTests.swift, add to the bottom of setUp(), just above sut.loadViewIfNeeded():

sut.api = UIApplication.appDelegate.api

If you build and either run or test, the app should continue to behave as before even though you’ve broken one dependency.

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.

Add the following code to API.swift, just underneath the import statements:

let UserLoggedInNotification =
  Notification.Name("user logged in")
enum UserNotificationKey: String {
  case userId
}

This adds a new notification for login and a key that will be used to get the user’s ID.

Before modifying more of the code, add the following test for the notification in APITests.swift:

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")
}

This test calls login(username:password:) and waits for the notification and checks that the notification has a userId in its userInfo. Run your tests and you will see that this test will not yet pass.

To get this test to pass, open API.swift and add the following to handleToken(token:), just before the call to loginSucceeded(userId:):

let note = Notification(name: UserLoggedInNotification,
                        object: self,
                        userInfo: [UserNotificationKey.userId:
                          token.userID.uuidString])
NotificationCenter.default.post(note)

This code will post the Notification. To make sure it gets called in the test, add the following override to MockAPI.swift:

override func login(username: String, password: String) {
  let token = Token(token: username, userID: UUID())
  handleToken(token: token)
}

Now, the test will build and pass! But you’re not done yet. You now need to move the login functionality from LoginViewController to AppDelegate.

In AppDelegate.swift add the following helper function:

func handleLogin(userId: String) {
  self.userId = userId

  let storyboard = UIStoryboard(name: "Main", bundle: nil)
  let tabController =
    storyboard.instantiateViewController(
      withIdentifier: "tabController")
  window?.rootViewController = tabController
}

This does the same logic as the loginSucceeded(userId:) callback. Next, add the following to setupListeners():

NotificationCenter.default
  .addObserver(
    forName: UserLoggedInNotification,
    object: nil,
    queue: .main) { note in
      if let userId =
        note.userInfo?[UserNotificationKey.userId] as? String {
          self.handleLogin(userId: userId)
      }
}

This adds the listener for the notification. Finally, in LoginViewController.swift, replace the contents of loginSucceeded(userId:) with an empty body.

If you build and test, the app will still have the same login/logout functionality — even if the chain of events from a login is now a little different.

Now you can update the dependency map once again:

Breaking the ErrorViewController dependency

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

It’s time to add characterization tests. Create a new Unit Test Case Class file named ErrorViewControllerTests.swift in CharacterizationTests/Cases and replace its contents with the following:

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)
  }
}

This adds three simple tests for the state of each button in the error view controller:

  • testViewController_whenSetToLogin_primaryButtonIsOK makes sure the primary button is titled ‘OK’.
  • testViewController_whenSetToLogin_showsTryAgainButton makes sure the secondary button is titled ‘Try Again’.
  • testViewController_whenDefault_secondaryButtonIsHidden makes sure that there is no secondary button in the default, or general, case.

Run the tests and observe that they all pass.

Ideally, there should also be a test for the secondary button that actually results in a try again action. Unfortunately, in its current state, it would be difficult to write a unit test due to how intertwined this class is with LoginViewController. In fact, that is one of the main motivators for breaking the dependency.

To write a test in the current state, you would have to script a good portion of the app to get the ErrorViewController to be set up correctly and bring in a fair amount of overall state to check that there was an effect when tapping the button. So, leave it for now. You’ll capture the try again behavior as part of breaking up the dependency.

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.

The way to break out this dependency is with a form of the Command pattern. That is, you’ll provide the necessary view information and behavior to the view controller so the button can invoke the try again behavior at run time. This pattern is a way for one object to provide implementation to another.

You’ll do this by adding the following struct at the top of the ErrorViewController class above enum AlertType:

struct SecondaryAction {
  let title: String
  let action: () -> ()
}

This struct contains the view information — title — and the behavior — action block. This is how view controllers will configure the error view going forward.

Create a new Unit Test Case Class in the MyBizTests target, named ErrorViewControllerTests.swift. Then replace its contents with the following:

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)
  }
}

This test follows the same pattern as your other view controller tests. There are two test cases: testSecondaryButton_whenActionSet_hasCorrectTitle and testSecondaryAction_whenButtonTapped_isInvoked. These cover only the new functionality of using the SecondaryAction. The first tests that the button’s title is set appropriately. The second checks that tapping the button performs the action block.

Of course, this test won’t yet compile, let alone run. Now, head back to ErrorViewController.swift.

Delete the AlertType enum. Then, replace the type variable with:

var secondaryAction: SecondaryAction? = nil

This property allows you to store the optional action. Then add this helper method:

private func updateAction() {
  guard let action = secondaryAction else {
    secondaryButton.removeFromSuperview()
    return
  }
  secondaryButton.setTitle(action.title, for: .normal)
}

To use it, in viewDidLoad, replace the switch type {...} statement with:

updateAction()

Now, when the view is loaded, it will call the helper method to set up the button.

Also, remove the setupLogin() method. Then, replace the body of secondaryAction(_:) with:

if let action = secondaryAction {
  dismiss(animated: true)
  action.action()
} else {
  Logger.logFatal("no action defined.")
}

This replaces the call to the LoginViewController with a simple invocation of the action block.

Now, if you try to build the project, you’ll see a compiler error. To begin fixing it, navigate to UIViewController+Alert.swift. Update the showAlert(title:subtitle:type:skin:) function signature with:

func showAlert(title: String,
       subtitle: String?,
       action: ErrorViewController.SecondaryAction? = nil,
       skin: Skin? = nil) {

This updates the alert to take an action instead of a type.

Next, replace:

alertController.type = type

with the following:

alertController.secondaryAction = action

Next, in LoginViewController.swift replace loginFailed(error:) with:

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)
}

This updated method uses the new showAlert signature to use the new action instead of type.

Finally, to finish the refactor, navigate to ErrorViewControllerTests.swift and make the following changes:

First, in whenDefault(), remove the sut.type = .general line.

Then, in whenSetToLogin, replace the sut.type = .login line with

sut.secondaryAction = .init(title: "Try Again", action: {})

Build and test and your tests will compile and pass. This means ErrorViewController is free from LoginViewController and you’re ready to move on to create a separate login module!

Take a look at your updated dependency map. There is a lot less red now:

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.

It’s also worth revisiting Section 3 on networking. The techniques taught in this section will help explain how to fix LoginViewControllerTests so that you could break up API and test its methods without having to use the MockAPI class.

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