Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

11. Legacy Problems
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.

Beginning TDD on a “legacy” project is much different than starting TDD on a new project. For example, the project may have few (if any) unit tests, lack documentation and be slow to build. This chapter will introduce you to strategies for tackling these problems.

You may think, “If only this project were created using TDD, it wouldn’t be this bad.” Making the code more testable while adding unit tests is a great way to address these issues. Unfortunately, there isn’t a silver-bullet, sure-fire way to fix all of these issues overnight.

However, there are great strategies you can use to introduce TDD to legacy projects over time. In this chapter, you’ll be introduced to the Legacy Code Change Algorithm, which was originally introduced by Michael Feathers in his book Working Effectively with Legacy Code. Here are the high-level steps:

  1. Identify change points
  2. Find test points
  3. Break dependencies
  4. Write tests
  5. Make changes and refactor

Introducing MyBiz

MyBiz is the sample app for this section. It’s a very lightweight ERP app but will be illustrative of the kinds of issues you may encounter working with legacy apps. Don’t worry if ERP is a meaningless acronym to you. It stands for Enterprise Resource Planning, which is a four-dollar expression for “kitchen sink of business crap.”

In our TDD-world, “legacy app” most importantly means an app without adequate (or any) unit tests. And if “legacy” means code without any tests, then this app is capital-L Legacy.

Bloated, convoluted apps are common in large enterprises, such as where MyBiz would be used; however, these issues occur in all kinds of apps in organizations of different sizes and maturities. As soon as that first feature is added to an app that wasn’t architected to support it, these “legacy (anti-) patterns” start cropping up. Introducing TDD in your legacy app while adding features is a great way to avoid this.

One challenge working with MyBiz is that it does not use a modern architecture like MVVM or VIPER. Instead, a lot of the business logic exists in monolithic view controllers. It gets the job done, but, as you’ll see, it’s hard to add new things.

Setting up the app and backend

Before launching the starter app, you should fire up the backend. Like the Dogpatch app in Section 3, this is a Vapor-based backend. It’s very barebones for an ERP app, which would normally talk to a big multi-tiered services architecture made up of multiple servers and databases. However, the goal of this project is just to have a functional app for adding features, tests and refactoring, so the backend is high level and abstract.

vapor xcode -y
Server starting on http://localhost:8080
Welcome to MyBiz!

Error: This image is missing a width attribute

Please provide one in the form of ![width=50%](images/mybiz.png)

Introducing the change task

To boost morale, the MyBiz HR Director has instituted a new policy of recognizing employee birthdays. As part of this process, you’ve been directed to add birthdays as events in the company calendar. For simplicity, assume that every user wants to see everyone else’s birthday.

Identifying a change point

To change an app, you must figure out where to put that change – that is, figure out which classes and files need to be modified. The first step is understanding the requirements so you know exactly what to implement.

Finding a test point

Test points are the locations where you need to write tests to support your changes. Test points aren’t about fixing bugs, they are to preserve existing app behavior. Just as the TDD process isn’t about finding bugs, it instead prevents bugs later on as changes are introduced.

Using the code in a test

First, you’ll need a place to put those characterization tests. To do that, create a new test target:

@testable import MyBiz
var sut: CalendarViewController!
override func setUpWithError() throws {
  try super.setUpWithError()
  sut = UIStoryboard(name: "Main", bundle: nil)
    .instantiateViewController(withIdentifier: "Calendar") as? CalendarViewController
  sut.loadViewIfNeeded()
}

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

Breaking dependencies

A logical place to start is where events are loaded into the calendar. If you add birthdays to the list of events, you want to make sure not to break the existing event functionality.

func testLoadEvents_getsData() {

}
// when
sut.loadEvents()
let predicate = NSPredicate { _, _ -> Bool in
  return !self.sut.events.isEmpty
}
let exp = expectation(
  for: predicate,
  evaluatedWith: sut,
  handler: nil)

// then
wait(for: [exp], timeout: 2)
print(sut.events)

Making the characterization into a test

This is not yet a true test since there is no assert, but this is a crucial step for characterizing the system as is.

[MyBiz.Event(name: "Alien invasion", date: 2021-11-05 12:00:00 +0000, type: MyBiz.EventType.appointment, duration: 3600.0), MyBiz.Event(name: "Interview with Hydra", date: 2021-11-05 17:30:00 +0000, type: MyBiz.EventType.appointment, duration: 1800.0), MyBiz.Event(name: "Panic attack", date: 2021-11-12 15:00:00 +0000, type: MyBiz.EventType.meeting, duration: 3600.0)]
let eventJson = """
  [{"name": "Alien invasion", "date": "2021-11-05T12:00:00+0000",
  "type": "Appointment", "duration": 3600.0},
    {"name": "Interview with Hydra", "date": "2021-11-05T17:30:00+0000",
  "type": "Appointment", "duration": 1800.0},
    {"name": "Panic attack", "date": "2021-11-12T15:00:00+0000",
  "type": "Meeting", "duration": 3600.0}]
  """
let data = Data(eventJson.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let expectedEvents = try? decoder.decode([Event].self, from: data)
XCTAssertEqual(sut.events, expectedEvents)

Adding a little stability

This is a good start but, as mentioned above, this test has a brittle dependency on the backend. Just wait a day and this test will no longer pass.

var api: API = UIApplication.appDelegate.api

@testable import MyBiz

class MockAPI: API {
  var mockEvents: [Event] = []

  override func getEvents() {
    DispatchQueue.main.async {
      self.delegate?.eventsLoaded(events: self.mockEvents)
    }
  }
}
var mockAPI: MockAPI!
mockAPI = MockAPI()
sut.api = mockAPI
mockAPI = nil
func testLoadEvents_getsData() {
  // given
  let eventJson = """
    [{"name": "Alien invasion", "date": "2019-04-10T12:00:00+0000",
    "type": "Appointment", "duration": 3600.0},
      {"name": "Interview with Hydra", "date": "2019-04-10T17:30:00+0000",
    "type": "Appointment", "duration": 1800.0},
      {"name": "Panic attack", "date": "2019-04-17T14:00:00+0000",
    "type": "Meeting", "duration": 3600.0}]
    """
  let data = Data(eventJson.utf8)
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .iso8601
  let expectedEvents = try! decoder.decode([Event].self, from: data)

  mockAPI.mockEvents = expectedEvents

  // when
  let predicate = NSPredicate { _, _ -> Bool in
    !self.sut.events.isEmpty
  }
  let exp = expectation(for: predicate, evaluatedWith: sut, handler: nil)

  sut.loadEvents()

  // then
  wait(for: [exp], timeout: 1)
  XCTAssertEqual(sut.events, expectedEvents)
}

Writing tests

Now, it’s time to add the birthday feature. Since this will be new code, you’ll use TDD to make sure there are tests in place and use those tests to guide your code.

import XCTest
@testable import MyBiz

class CalendarModelTests: XCTestCase {
  var sut: CalendarModel!

  override func setUpWithError() throws {
    try super.setUpWithError()
    sut = CalendarModel()
  }

  override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
  }
}
class CalendarModel {
  init() {}
}
func mockEmployees() -> [Employee] {
  let employees = [
    Employee(
      id: "Cap",
      givenName: "Steve",
      familyName: "Rogers",
      location: "Brooklyn",
      manager: nil,
      directReports: [],
      birthday: "07-04-1920"),
    Employee(
      id: "Surfer",
      givenName: "Norrin",
      familyName: "Radd",
      location: "Zenn-La",
      manager: nil,
      directReports: [],
      birthday: "03-01-1966"),
    Employee(
      id: "Wasp",
      givenName: "Hope",
      familyName: "van Dyne",
      location: "San Francisco",
      manager: nil,
      directReports: [],
      birthday: "01-02-1979")
  ]
  return employees
}

func mockBirthdayEvents() -> [Event] {
  let dateFormatter = DateFormatter()
  dateFormatter.dateFormat = Employee.birthdayFormat
  return [
    Event(
      name: "Steve Rogers Birthday",
      date: dateFormatter.date(from: "07-04-1920")!.next()!,
      type: .birthday,
      duration: 0),
    Event(
      name: "Norrin Radd Birthday",
      date: dateFormatter.date(from: "03-01-1966")!.next()!,
      type: .birthday,
      duration: 0),
    Event(
      name: "Hope van Dyne Birthday",
      date: dateFormatter.date(from: "01-02-1979")!.next()!,
      type: .birthday,
      duration: 0)
  ]
}

func testModel_whenGivenEmployeeList_generatesBirthdayEvents() {
  // given
  let employees = mockEmployees()

  // when
  let events = sut.convertBirthdays(employees)

  // then
  let expectedEvents = mockBirthdayEvents()
  XCTAssertEqual(events, expectedEvents)
}
let birthday: String?
static let birthdayFormat = "MM-dd-yyyy"
case birthday = "Birthday"
case .birthday:
  return "🎂"
func convertBirthdays(_ employees: [Employee]) -> [Event] {
  let dateFormatter = DateFormatter()
  dateFormatter.dateFormat = Employee.birthdayFormat
  return employees.compactMap {
    if let dayString = $0.birthday,
      let day = dateFormatter.date(from: dayString),
      let nextBirthday = day.next() {
      let title = $0.displayName + " Birthday"
      return Event(
        name: title,
        date: nextBirthday,
        type: .birthday,
        duration: 0)
    }
    return nil
  }
}

Loading birthdays in production

You’re now able to create Events from employee birthdays, but you don’t yet have a way to load birthdays in production code. You’ll work on that next.

func testModel_whenBirthdaysLoaded_getsBirthdayEvents() {
  // given
  let exp = expectation(description: "birthdays loaded")

  // when
  var loadedEvents: [Event]?
  sut.getBirthdays { res in
    loadedEvents = try? res.get()
    exp.fulfill()
  }

  // then
  wait(for: [exp], timeout: 1)
  let expectedEvents = mockBirthdayEvents()
  XCTAssertEqual(loadedEvents, expectedEvents)
}
func getBirthdays(
  completion: @escaping (Result<[Event], Error>) -> Void) {
}
let api: API
var birthdayCallback: ((Result<[Event], Error>) -> Void)?

init(api: API) {
  self.api = api
}
birthdayCallback = completion
api.delegate = self
api.getOrgChart()
extension CalendarModel: APIDelegate {
  func orgLoaded(org: [Employee]) {
    let birthdays = convertBirthdays(org)
    birthdayCallback?(.success(birthdays))
    birthdayCallback = nil
  }

  func orgFailed(error: Error) {
    // TBD - use the callback with an failure result
  }

  func eventsLoaded(events: [Event]) {}
  func eventsFailed(error: Error) {}
  func loginFailed(error: Error) {}
  func loginSucceeded(userId: String) {}
  func announcementsFailed(error: Error) {}
  func announcementsLoaded(announcements: [Announcement]) {}
  func productsLoaded(products: [Product]) {}
  func productsFailed(error: Error) {}
  func purchasesLoaded(purchases: [PurchaseOrder]) {}
  func purchasesFailed(error: Error) {}
  func userLoaded(user: UserInfo) {}
  func userFailed(error: Error) {}
}
var mockAPI: MockAPI!
Use of undeclared type 'MockAPI'

sut = CalendarModel()
mockAPI = MockAPI()
sut = CalendarModel(api: mockAPI)
mockAPI = nil
// MARK: - Org
var mockEmployees: [Employee] = []

override func getOrgChart() {
  DispatchQueue.main.async {
    self.delegate?.orgLoaded(org: self.mockEmployees)
  }
}
mockAPI.mockEmployees = mockEmployees()

Making a change and refactoring

The final piece in adding the birthday feature is to refactor the view controller to use the new model and put the birthdays into the calendar view.

func testModel_whenEventsLoaded_getsEvents() {
  // given
  let expectedEvents = mockEvents()
  mockAPI.mockEvents = expectedEvents
  let exp = expectation(description: "events loaded")

  // when
  var loadedEvents: [Event]?
  sut.getEvents { res in
    loadedEvents = try? res.get()
    exp.fulfill()
  }

  // then
  wait(for: [exp], timeout: 1)
  XCTAssertEqual(loadedEvents, expectedEvents)
}
func mockEvents() -> [Event] {
  let events = [
    Event(
      name: "Event 1",
      date: Date(),
      type: .appointment,
      duration: .hours(1)),
    Event(
      name: "Event 2",
      date: Date(timeIntervalSinceNow: .days(20)),
      type: .meeting,
      duration: .minutes(30)),
    Event(
      name: "Event 3",
      date: Date(timeIntervalSinceNow: -.days(1)),
      type: .domesticHoliday,
      duration: .days(1))
  ]
  return events
}
var eventsCallback: ((Result<[Event], Error>) -> Void)?

func getEvents(
  completion: @escaping (Result<[Event], Error>) -> Void) {

  eventsCallback = completion
  api.delegate = self
  api.getEvents()
}
func eventsLoaded(events: [Event]) {
  eventsCallback?(.success(events))
  eventsCallback = nil
}

Updating the view controller

To help with writing more tests, move mockBirthdayEvents and mockEmployees from CalendarModelTests.swift to MockAPI.swift (outside the class below mockEvents()) so they can be re-used in multiple files.

@testable import MyBiz
var sut: CalendarViewController!
var mockAPI: MockAPI!

override func setUpWithError() throws {
  super.setUp()
  sut = UIStoryboard(name: "Main", bundle: nil)
    .instantiateViewController(withIdentifier: "Calendar")
  as? CalendarViewController

  mockAPI = MockAPI()
  sut.api = mockAPI
  sut.loadViewIfNeeded()
}

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

func testLoadEvents_getsBirthdays () {
  // given
  mockAPI.mockEmployees = mockEmployees()
  let expectedEvents = mockBirthdayEvents()

  // when
  let predicate = NSPredicate { _, _ -> Bool in
    !self.sut.events.isEmpty
  }
  let exp = expectation(
    for: predicate,
    evaluatedWith: sut,
    handler: nil)

  sut.loadEvents()

  // then
  wait(for: [exp], timeout: 1)
  XCTAssertEqual(sut.events, expectedEvents)
}
var model: CalendarModel!
model = CalendarModel(api: api)
func loadEvents() {
  events = []
  model.getBirthdays { res in
    if let newEvents = try? res.get() {
      self.events.append(contentsOf: newEvents)
      self.calendarView.reloadData()
    }
  }
  model.getEvents { res in
    if let newEvents = try? res.get() {
      self.events.append(contentsOf: newEvents)
      self.calendarView.reloadData()
    }
  }
}

Challenges

The next few chapters will cover these types of changes in greater detail, so the challenge here is pretty light:

Challenge 1: Add error handling

Go back and add error handling for the CalendarViewController. As a hint, you’ll need a way to mock API errors and handle them in the CalendarModel as well as the view controller.

Challenge 2: Clean up the code

Clean up the code and make it a little more reliable if there was a single call to the model for loading the events, instead of two.

Key points

In this chapter, you added a “small” feature of placing calendar events for employee birthdays following the code change algorithm. Here are the key points:

Where to go from here?

This chapter’s concepts are laid out in the Working Effectively with Legacy Code by Michael Feathers, which is a helpful read if you want to learn more of the motivating theory.

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