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

14. Modularizing Dependencies
Written by Michael Katz

Splitting an app into modules, whether they be frameworks, static libraries or just structurally-isolated code, is an important part of clean coding. Having files with related concerns at the same level of abstraction makes your code easier to maintain and reuse across projects.

In this chapter, you’ll continue the work from the last chapter, further breaking MyBiz into modules so you can reuse the login functionality. You’ll learn how to define clean boundaries in the code to create logical units. Through the use of tests, you’ll make sure the new architecture works and the app continues to function.

Making a place for the code to go

There are several ways to modularize an app. In this tutorial, you’ll use the most common and easiest: A new dynamic framework. You can reuse a framework in many iOS projects and distribute it through tools like Cocoapods, Carthage or Swift Package Manager.

Even if you completed the challenge from the last chapter, start with this chapter’s starter project. That way, you won’t have any discrepancies with file or test names.

Let’s start by creating the new framework:

  1. From the Project editor, create a new target. Choose the Framework template to create a dynamic framework and click Next.
  2. Set the Product Name to Login.
  3. Make sure you’ve checked Include Unit Tests. This sets you up to add tests right away!

  1. Click Finish.
  2. Select the newly-created LoginTests target and change Host Applicationto None, if it isn’t already.
  3. Select Build Phases and make sure Login is the only dependency. Remove MyBiz as a dependency.

Moving files

The dependency map is free of cycles around LoginViewController, so now you can finally move some files.

Grab the two “green” files LoginViewController.swift and Validators.swift and drag them from the MyBiz target to the Login target.

Double-check that you’ve changed these files’ Target Membership from MyBiz to Login.

Build and run your app and you’ll see a lot of red errors. LoginViewController may now be free of bad dependencies, but it’s not free of dependencies altogether. You’ll see by the number of issues that this won’t be as easy as it might first seem.

First, classes like Skin and ErrorViewController are dependencies of both LoginViewController and other classes in MyBiz. To prevent copying or introducing circular dependencies, you’ll need to create yet another framework.

Create a new Framework named UIHelpers using the same steps as above. Be sure to also Include Unit Tests.

Move the following files to the new target:

  • UIViewController+Alert.swift
  • ErrorViewController.swift
  • Skin.swift
  • Styler.swift
  • Colors.swift

To simplify this refactoring process, switch the scheme to the auto-created one for UIHelpers. This way, only this library will build, which will reduce the noise from other build errors.

Breaking up Styler’s dependencies

The first error you may notice is in Styler.swift. Styler relies on a configuration from the AppDelegate. It breaks encapsulation to refer to the app delegate in this helper framework, so you’ll need another way to set the configuration.

Configuration itself is also an issue because, in addition to UI styling, it contains things like business logic and server setup. The easiest way to move forward is to start from the bottom and move your way up.

Create a new Swift File in the UIHelpers target: UIConfiguration.swift.

Move the UI substruct from Configuration.swift to this new file and rename it UIConfiguration:

struct UIConfiguration: Codable {
  struct Button: Codable {
    let cornerRadius: Double
    let borderWidth: Double
  }
  let button: Button
}

Next, in Styler.swift change the let configuration line to:

var configuration: UIConfiguration?

This has to be set before you can use it; it’s no longer guaranteed to be set.

In style(button:skin:), replace the two middle button.layer lines with the following:

button.layer.cornerRadius = CGFloat(configuration?.button.cornerRadius ?? 0)
button.layer.borderWidth = CGFloat(configuration?.button.borderWidth ?? 0)

Finally, in ErrorViewController.swift, you’ll see a dependency on Logger. You’ll revisit this dependency in this chapter’s challenge section. For now, comment out this line of code.

Now, the framework will build successfully.

Note: It would be reasonable to perform the same dependency map exercise on these files as you did for LoginViewController. That would involve going through ErrorViewController’s dependencies to find the problematic relationships and correct them. We bypassed that step here because it’s straightforward and also so this tutorial could fit into a book.

Modularizing a storyboard

In the app, you create an ErrorViewController via a storyboard. You do this explicitly in UIViewController+Alert.swift through the Main storyboard. Since this storyboard lives in an app module, it’s not available to this framework.

To fix this, move the view controller to a new storyboard in the UIHelpers framework by following these steps:

  1. Open Main.storyboard and select the Error View Controller Scene.
  2. Now, you can use an Xcode tool to help. Select Editor ▸ Refactor to Storyboard….
  3. Name it UIHelpers.storyboard.
  4. Change the Group to UIHelpers.
  5. Uncheck the MyBiz target and check the UIHelpers target instead.
  6. Click Save.
  7. In Main.storyboard, delete the Error Scene reference.

Next, in UIViewController+Alert.swift, replace the let alertController = ... line with:

let thisBundle = Bundle(for: ErrorViewController.self)
let storyboard = UIStoryboard(name: "UIHelpers",
                              bundle: thisBundle)
let alertController = storyboard.instantiateViewController(withIdentifier: "error")
  as! ErrorViewController

This now loads the same scene, but from a new storyboard that lives within the framework.

Moving tests

What about the tests? You already have some test cases that cover ErrorViewController. You can move those, too.

In UIHelpersTests delete UIHelpersTests.swift.

Next, create a Cases group in UIHelpersTests and move ErrorViewControllerTests.swift from MyBizTests to it. Verify that the target membership changed to UIHelpersTests. Change the @testable import line to:

@testable import UIHelpers

Next, replace setUp() with the following:

override func setUp() {
  super.setUp()
  sut = UIStoryboard(name: "UIHelpers",
                     bundle: Bundle(for:
                              ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
    as? ErrorViewController
}

This new setUp uses the new UIHelpers.storyboard you created.

Make sure that you’ve enabled the tests in this target. To check, open the Test Navigator, right-click on UIHelpersTests and select Enable “UIHelperTests”.

Now, you can build and test just the UIHelpers scheme and feel a bit more confident that this major refactor will work.

Note: For some of the characterization tests, you need a live connection to the backend. The setup and launch instructions are in Chapter 13, “Legacy Problems”.

Using the new framework with Login

Now that you have given the UI helpers their own framework, you need to tell the Login framework about it.

In the Project editor, select Login. Under Frameworks and Libraries, add UIHelpers. When that’s done, it should look like this:

Add this import to the top of LoginViewController.swift:

import UIHelpers

Next, you’ll have to fix some access levels in UIHelpers. When all the files were in the same target, the default internal access was fine, but now you’ll need to make some things public.

In UIHelpers make the following things public:

  • Skin.
  • All of the static let constants in Skin.
  • Styler.
  • In Styler: class, shared, configuration and all the style methods.
  • In UIViewController+Alert.swift: showAlert.
  • UIConfiguration.
  • All of the class var in Colors.swift.
  • ErrorViewController and its SecondaryAction and viewDidLoad().

You also need to add this initializer to SecondaryAction:

public init(title: String, action: @escaping () -> ()) {
  self.title = title
  self.action = action
}

This now exposes these types and functions for other modules to consume. In this case, those modules will be Login and MyBiz.

Further isolating LoginViewController

Change the build scheme now to Login and build and run. You’ll still get a lot of compiler errors.

Cleaning up LoginViewController will require fixing a long-time annoyance: The API class is too broad and relies on weird delegates with lots of extra methods. You can scope API by creating a new protocol that only contains the pieces related to Login.

Create a new Swift file named LoginAPI under Login and replace its contents with the following:

public protocol LoginAPI {
  func login(username: String,
         password: String,
         completion: @escaping (Result<String, Error>) -> ())
}

This code accomplishes two life-changing code and architectural clean-ups. First, LoginAPI only has the one method that concerns Login. Second, it replaces the obnoxious catch-all delegate with a simple completion block that uses a Result. Conceptually, it would also make sense to add Logout, but you can save that for a future improvement.

To make use of the new protocol, go back to LoginViewController:

  1. Change the type of api to LoginAPI!.
  2. In viewDidLoad(), remove the line api.delegate = self.
  3. In signIn(_:), replace the call to api.login with:
api.login(username: username, password: password) { result in
  if case .failure(let error) = result {
    self.loginFailed(error: error)
  }
}
  1. In the extension, remove the APIDelegate type, and remove every method other than loginFailed(error:).

Ah, so much cleaner! I can’t understate the power of this change. To see the results visually, look at this updated dependency map, now API is no longer in the picture:

Don’t forget the tests

Next, you’ll want to add tests to your protocol to verify the changes you’ve just made.

First, grab ValidatorsTests.swift and drag it to the LoginTests target, making sure the target changes to LoginTests as well.

Next, delete LoginTests.swift since you don’t need this file.

Finally, open ValidatorsTests.swift, and replace:

@testable import MyBiz

with the following:

@testable import Login

Build and run the Login target and tests to verify everything is working as intended. The same conditions apply as with UIHelpersTests: Make sure there is no host application and the target and scheme do not try to build MyBiz.

Fixing MyBiz

Now that you have two new frameworks that contain previously-available code, you’ll need to fix up the dependencies their usage project. Switch back to the MyBiz scheme and you’ll start seeing all sorts of build errors.

Don’t worry, you’ll, tackle them one at a time and the project will straighten out in a jiffy (or is it giffy? :]).

First, add the following import statement to the following files:

import UIHelpers
  • DateSelectingViewController.swift
  • AnnouncementsTableViewController.swift
  • CreatePurachaseOrderTableViewController.swift
  • PurchasesTableViewController.swift
  • OrgTableViewController.swift
  • AddToOrderTableViewController.swift
  • Configuration.swift

Next, in Configuration.swift, replace:

let ui: UI

with the following:

let ui: UIConfiguration

This takes care of the UIHelpers framework, but you’ll also need to use the Login framework.

Open AppDelegate.swift, and add the following below import UIKit:

import Login

To fix the errors, open LoginViewController.swift and make LoginViewController, viewDidLoad() and api public.

Now it’s time to tackle the trickiest part: The API.

Open API.swift, and add the following below import Foundation:

import Login

Next, you’ll want to replace the existing login. Still inside API.swift, replace the existing login(username:password:) and handleToken(token:) with the following:

func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> ()) {

  let eventsEndpoint = server + "api/users/login"
  let eventsURL = URL(string: eventsEndpoint)!
  var urlRequest = URLRequest(url: eventsURL)
  urlRequest.httpMethod = "POST"
  let data = "\(username):\(password)".data(using: .utf8)!
  let basic = "Basic \(data.base64EncodedString())"
  urlRequest.addValue(basic,
                    forHTTPHeaderField: "Authorization")
  let task = session.dataTask(with: urlRequest)
  { data, _, error in
    guard let data = data else {
      if error != nil {
        DispatchQueue.main.async {
          completion(.failure(error!))
        }
      }
      return
    }
    let decoder: JSONDecoder = JSONDecoder()
    if let token = try? decoder.decode(Token.self,
                                       from: data) {
      self.handleToken(token: token, completion: completion)
    } else {
      do {
        let error = try decoder.decode(APIError.self,
                                       from: data)
        DispatchQueue.main.async {
          completion(.failure(error))
        }
      } catch {
        DispatchQueue.main.async {
          completion(.failure(error))
        }
      }
    }
  }
  task.resume()
}

func handleToken(token: Token,
                 completion: @escaping (Result<String, Error>) -> ()) {
  self.token = token
  Logger.logDebug("user \(token.userID)")
  DispatchQueue.main.async {
    let note = Notification(name: UserLoggedInNotification,
                            object: self,
                            userInfo: [UserNotificationKey.userId:
                                        token.userID.uuidString])
    NotificationCenter.default.post(note)
    completion(.success(token.userID.uuidString))
  }
}

This code is mostly the same as before except instead of calling the delegate, it now calls the passed in completion block instead. Finally, change the class definition to:

class API: LoginAPI

Next, you can clean up APIDelegate by removing loginFailed(error:) and loginSucceeded(userId:) from the protocol definition.

Finally, remove loginFailed(error:) and loginSucceeded(userId:) from the APIDelegate conformance extensions in the following files:

  • AnnouncementsTableViewController.swift
  • CalendarModel.swift
  • OrgTableViewController.swift
  • PurchasesTableViewController.swift
  • CreatePurachaseOrderTableViewController.swift
  • SettingsTableViewController.swift

Build and run MyBiz and you’ll find it now builds. If only that was all you needed to do…

Fixing the storyboard

Even though the app builds, it does not yet run or pass the tests. The next stop on this refactor train is to work on the storyboard.

Open Main.storyboard. Select the Login View Controller Scene. In the Identity inspector, change the Module to Login. You could also extract a separate storyboard for the login framework, which is part of this chapter’s challenge, which you’ll come to later.

Now the app will build and run and work just the same as before.

Fixing the tests

There are a few build issues to fix in the tests.

In MockAPI.swift, replace the existing override of login with:

override func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> ()) {

  let token = Token(token: username, userID: UUID())
  handleToken(token: token, completion: completion)
}

This code adds the completion argument introduced when you replaced the login and handleToken methods above.

In SpyAPI.swift, add the completion argument you created earlier by replacing the override of login with:

override func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> ()) {

  loginCalled = true
  super.login(username: username,
              password: password,
              completion: completion)
}

Next, open APITests.swift, find testAPI_whenLogin_generatesANotification(), and replace the when line with the following:

sut.login(username: "test", password: "test") { _ in }

Open LoginViewControllerTests.swift, and add the following below @testable import MyBiz:

@testable import Login
@testable import UIHelpers

Next, in the characterization test ErrorViewControllerTests.swift, add the following below @testable import MyBiz:

@testable import UIHelpers

Finally, to use the correct storyboard, replace setup() with the following:

override func setUp() {
  super.setUp()
  sut = UIStoryboard(
    name: "UIHelpers",
    bundle: Bundle(for: ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
    as? ErrorViewController
}

Now, all tests will pass once again, and you can take a deep sigh of relief. The refactor didn’t break anything!

Wrap up

Pat yourself on the back. Login is now in its own framework and ready to be re-used in another project. You’ll have to distribute both the Login framework and the UIHelpers frameworks, but it’s normal for frameworks to have their own dependencies.

Take a look at the final dependency map, updated to reflect the changes to API:

It’s a nice, clean and hierarchical diagram. There are no cycles and you haven’t pulled in any extraneous data types or unrelated functionality. Good job!

Challenges

This chapter walked you through the minimum amount of work to cleanly pull the Login functionality into its own framework. However, there’s (a lot of!) room for improvement. Fix up the project by completing any of the following:

  1. LoginViewController still relies on Main.storyboard in the MyBiz module, which makes it harder to reuse. Pull it out into its own storyboard that lives within the framework.
  2. Add and improve the login tests by:
  • Pulling the LoginViewControllerTests characterization tests into the LoginTests target.
  • Repurposing those test cases as unit tests by creating a mock LoginAPI so you don’t have to go through API and the local server.
  • Creating an AppDelegateTests that tests the user state flow.
  1. Fix the Logger issue by either bringing it into UIHelpers and passing its configuration in like Styler OR by creating a logging protocol and attaching it to the frameworks.

Key points

  • Frameworks help organize code and keep the separation of dependencies clean.
  • Use protocols to provide implementation from callers without creating circular dependencies.
  • Write tests before, during and after a large refactor.

Where to go from here?

Gosh, that was a lot of work, but you really cleaned up the code. There are a few areas that are worth investigating in the future to improve your architectural hygiene. Some of these were suggested in the Challenge, but you can achieve even more improvement with a dedicated user state manager, and by using a pattern like Router or FlowController to handle showing the error and login screens, rather than relying upon AppDelegate.

Other great resources are the original Design Patterns book (Gamma et al) which, although very object-oriented, contains a lot of useful patterns for incrementally separating dependencies and breaking out functionality. More immediately useful would be these architecture books at https://store.raywenderlich.com/:

  • Design Patterns by Tutorials
  • Combine: Asynchronous Programming with Swift or RxSwift: Reactive Programming with Swift
  • Advanced iOS App Architecture
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.