Unit Tests and Working with Legacy Code

Much has been written about writing good software and how to refactor a codebase when you want to add or remove features but most of the existing literature uses C or Java in it’s examples. Here I will attempt to show how iOS Developers can use XCTest to aid in maintaining legacy Swift code.

What is legacy code?

Legacy code is basically any existing code — it doesn’t matter how old it is. But the older it is, the less likely you are to be familiar with it — even if you wrote it. And you want to be able to modify it with confidence that you won’t break existing functionality.

What are we trying to achieve?

There are many reasons for having to touch legacy code but they generally fall into the following categories:

If we’re lucky we don’t need to make any major changes to do any of the above, but often we need to refactor the code to achieve our goal. This could be as simple as taking a few lines of code and extracting them to a separate function, or as complicated as breaking an object up into several separate objects, resulting in the need for changes throughout the codebase wherever the old code was called from.

The problem is, we don’t want to introduce any bugs, and unless we’re intimately familiar with the app, or the codebase is very simple, it’s highly likely that we will break something.

Unit tests are our friend

Before we go changing any code it’s important to be sure we understand how the app functions first and be able to know if we’ve changed that functionality after we’ve changed the code.

So that I can demonstrate how unit tests can help, lets imagine we have an app that can display the name and address of a person in a UILabel. We’ll have a simple Person object and a view model with a single function that returns the text for the label.

struct Person {
  let name: String
  let address: String
}

struct ViewModel {
  var person: Person
  var nameAndAddress: String {
    return "\(person.name), \(person.address)"
  }
  
  init() {
    person = Person(name: "John Appleseed", 
                    address: "1 Infinite Loop, Cupertino, CA")
  }
}

Step 1 is to understand what this code does by writing a failing test.

func testResultOfNameAndAddressFunc() {
  XCTAssertEqual(ViewModel().nameAndAddress, "")
}

This will fail with an error:

    XCTAssertEqual failed: (“John Appleseed, 1 Infinite Loop, Cupertino, CA”) is not equal to (“”) —

To make our test pass we can copy the output string into the test case:

func testResultOfNameAndAddressFunc() {
  XCTAssertEqual(ViewModel().nameAndAddress, 
                 "John Appleseed, 1 Infinite Loop, Cupertino, CA")
}

Refactoring

Now that we have a unit test, we can start to refactor. So lets say we want to split the name into two separate properties on the Person class, so we can display them in two separate labels on another screen in our UI. But we still want to display the original label and we need to be sure that the existing behaviour hasn’t been broken.

struct Person {
  let firstName: String
  let lastName: String
  let address: String
}

And lets get the code compiling by updating the view model too:

struct ViewModel {
  var person: Person
  var nameAndAddress: String {
   return "\(person.firstName)\(person.lastName), \(person.address)"
  }

  init() {
    person = Person(firstName: "John", 
                    lastName: "Appleseed", 
                    address: "1 Infinite Loop, Cupertino, CA")
  }
}

When we run the test we’ll discover we’ve introduced a bug in the formatting of the output text because we forgot to put a space between the first name and the last name:

    XCTAssertEqual failed: (“**JohnAppleseed**, 1 Infinite Loop, Cupertino, CA”) is not equal to (“**John Appleseed**, 1 Infinite Loop, Cupertino, CA”) —

This is a really basic example, but it shows how writing a unit test first can help us make sure we don’t create bugs when we refactor our code. It’s worth pointing out here that this test would have been much harder to write if the code to generate the name and address string was inside a view controller. It’s a good example of the kind of logic that should live inside a view model object. MVVM — Model, View, View Model — is a very handy pattern to follow if you have logic that you’d like to unit test. But if you have your logic in a view model and it’s still hard to test, then that indicates your code should be refactored. That could involve extracting the logic to separate objects, or splitting the logic into separate testable functions. A topic for another post.

Acknowledgements

Adding unit tests for legacy functionality is just one strategy for dealing with modifying legacy code. For details of other strategies and techniques I recommend Michael Feather’s book “Working Effectively with Legacy Code.”

While writing this post I used a Playground and I learned how to get XCTests to run within the playground, thanks to Stuart Sharp’s post about TDD in Swift Playgrounds.

Written by
Ben Thomas — Ben was an iOS Developer for Itty Bitty Apps. You can follow him on Twitter @iOSDevBen