11
Legacy Problems
Written by Michael Katz
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:
- Identify change points
- Find test points
- Break dependencies
- Write tests
- 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!
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 setUp() {
super.setUp()
sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "Calendar") as? CalendarViewController
sut.loadViewIfNeeded()
}
override func tearDown() {
sut = nil
super.tearDown()
}
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 exp = expectation(for: NSPredicate(block: { vc, _ -> Bool in
return !(vc as! CalendarViewController).events.isEmpty
}), 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: 2019-04-10 12:00:00 +0000, type: MyBiz.EventType.Appointment, duration: 3600.0), MyBiz.Event(name: "Interview with Hydra", date: 2019-04-10 17:30:00 +0000, type: MyBiz.EventType.Appointment, duration: 1800.0), MyBiz.Event(name: "Panic attack", date: 2019-04-17 14:00:00 +0000, type: MyBiz.EventType.Meeting, duration: 3600.0)]
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 = 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 dependency on the backend that is brittle. Just wait a day and this test will no longer pass. You should continue to break dependencies until the test no longer depends on live API calls. “Restful Networking,” covers the theories and strategies for how to do this. In this next step, you’ll do a light version of that, using a mock that overrides production code, in order to be able to proceed on the original goal: adding birthdays.
var api: API = (UIApplication.shared.delegate as! 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 = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let expectedEvents = try! decoder.decode([Event].self, from: data)
mockAPI.mockEvents = expectedEvents
// when
let exp = expectation(for: NSPredicate(block: { vc, _ -> Bool in
return !(vc as! CalendarViewController).events.isEmpty
}), 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 setUp() {
super.setUp()
sut = CalendarModel()
}
override func tearDown() {
sut = nil
super.tearDown()
}
}
class CalendarModel {
}
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
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
}
}
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
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 setUp() {
super.setUp()
sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "Calendar")
as? CalendarViewController
mockAPI = MockAPI()
sut.api = mockAPI
sut.loadViewIfNeeded()
}
override func tearDown() {
mockAPI = nil
sut = nil
super.tearDown()
}
func testLoadEvents_getsBirthdays () {
// given
mockAPI.mockEmployees = mockEmployees()
let expectedEvents = mockBirthdayEvents()
// when
let exp = expectation(for: NSPredicate(block: {
vc, _ -> Bool in
return !(vc as! CalendarViewController).events.isEmpty
}), 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.