Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

14. Modularizing Dependencies
Written by Michael Katz

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 Application to 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.

Xpus OOZaewRavbmuvpuf +Awepj Unlun RaupSiprcowxin EdmMecuxuli Fikuvq Ofupr Enmbuhue Orkuogmotubc Xvidevz SipyfehaEfduf Bixdovusudaap UTUZosafodi Hegay Dunezacocp Vituf CoogNiltxuyduk UXA

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.

struct UIConfiguration: Codable {
  struct Button: Codable {
    let cornerRadius: Double
    let borderWidth: Double
  }
  let button: Button
}
var configuration: UIConfiguration?
button.layer.cornerRadius 
  = CGFloat(configuration.ui.button.cornerRadius)
button.layer.borderWidth 
  = CGFloat(configuration.ui.button.borderWidth)      
button.layer.cornerRadius 
  = CGFloat(configuration?.button.cornerRadius ?? 0)
button.layer.borderWidth 
  = CGFloat(configuration?.button.borderWidth ?? 0)

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.

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

Moving tests

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

@testable import UIHelpers
override func setUpWithError() throws {
  try super.setUpWithError()
  sut = UIStoryboard(
    name: "UIHelpers",
    bundle: Bundle(for: ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
  as? ErrorViewController
}

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.

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

Further isolating LoginViewController

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

public protocol LoginAPI {
  func login(
    username: String,
    password: String,
    completion: @escaping (Result<String, Error>) -> Void
  )
}
@IBAction func signIn(_ sender: Any) {
  guard let username = emailField.text,
    let password = passwordField.text else { return }
  guard username.isEmail && password.isValidPassword else {
    // a little client-side validation ;)
    showAlert(
      title: "Invalid Username or Password",
      subtitle: "Check the username or password")
    return
  }
  api.login(username: username, password: password) { result in
    if case .failure(let error) = result {
      self.loginFailed(error: error)
    }
  }
}
Jibojikunq EphVeyupoja Qonfifesaxuaf Senew PeulYeppqoqcuk YewasAGE UIBetherq

Don’t forget the tests

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

@testable import MyBiz
@testable import Login

Fixing MyBiz

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

import UIHelpers
let ui: UI
let ui: UIConfiguration
import Login
import Login
func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> Void
) {
  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()
    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>) -> Void
) {
  self.token = token
  Logger.logDebug("user \(token.user.id)")
  DispatchQueue.main.async {
    let note = Notification(
      name: userLoggedInNotification,
      object: self,
      userInfo: [
        UserNotificationKey.userId: token.user.id.uuidString
      ])
    NotificationCenter.default.post(note)
    completion(.success(token.user.id.uuidString))
  }
}
class API: LoginAPI

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.

Fixing the tests

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

override func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> Void
) {
  let token = Token(token: username, userID: UUID())
  handleToken(token: token, completion: completion)
}
override func login(
  username: String,
  password: String,
  completion: @escaping (Result<String, Error>) -> Void
) {
  loginCalled = true
  super.login(
    username: username,
    password: password,
    completion: completion)
}
sut.login(username: "test", password: "test") { _ in }
@testable import Login
@testable import UIHelpers
@testable import UIHelpers
override func setUpWithError() throws {
  try super.setUpWithError()
  sut = UIStoryboard(
    name: "UIHelpers",
    bundle: Bundle(for: ErrorViewController.self))
    .instantiateViewController(withIdentifier: "error")
    as? ErrorViewController
}

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.

Hapeqonals AxhJezorope Xerborekopooy Kebev XiaqGikwkonwaf YazugIVU UWE OOMoxcoft

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:

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.

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now