VAPOR 3: Learn Server-Side Swift with hands-on projects >>

How to refactor your app to add unit tests

Paul Hudson    May 6th 2018    @twostraws

Part 1 in a series of tutorials on modern app infrastructure:

  1. How to refactor your code to add tests
  2. How to add CocoaPods to your project
  3. How to clean up your code formatting with SwiftLint
  4. How to streamline your development with Fastlane
  5. How to save and share your work with GitHub
  6. How to validate code changes using CircleCI

Every developer has, at some point, written terrible code. It might have been because you were early in your career, it might have been because you didn’t know any better at the time, it be because you were under pressure and had to rush, or it might have been for any number of other reasons.

I’ve already written extensively about ways to fix massive view controllers (see here, here, and here), but in this tutorial series I want to focus on app infrastructure – tools that help you write better software by automating tasks.

To make things as realistic as possible, I built a Swift project that contains a number of problems that we’ll investigate here and across the following tutorials in this series.

Warning: This project has been written specifically for this tutorial series, and contains mistakes and problems that we’ll be examining over this tutorial series. If you’re looking for example code to learn from, this is the wrong place.

To get started, download the project from GitHub here. The app is designed to store and display famous quotes, and I recommend you give it a quick try before continuing:

  1. You can tap any quote to see it.
  2. While viewing a quote, tapping the action button shares it.
  3. You can press “Random” from the quote list to see a random quote.
  4. If you click + you’ll be prompted to add a new quote.
  5. In the quote list, you can swipe left on any quote to edit or delete it – try deleting one to make sure it works.
  6. If you try to add or edit an empty quote – no author or quote – it will be removed.
  7. There’s a default list of quotes that gets loaded from initial-quotes.json, but as you make changes your edits get saved for next time.

So, the app itself is pretty trivial – the kind of thing you might have built when you were learning iOS development but were already sick of todo apps.

The app has a number of infrastructure, and in this first installment we’re going to look at unit testing. More specifically, this project doesn’t have any unit tests, and it’s been designed so badly that unit tests would be hard to write.

If you’ve written this kind of code yourself, relax – you’re not alone. We’re going to start by refactoring the app to make it easier to test, then go ahead and write a handful of tests to the finished product. This is by far the longest part in this infrastructure series, and it’s going to take a fair amount of work – hopefully you’ll learn new things along the way!

Exploring the problem

I’ve said that this app has been designed badly, but before we try to fix things I want to walk through a few of its problems. These aren’t all the problems I would fix if this were a real app, but they are certainly the ones that make testing hard:

  • The viewDidLoad() method of QuotesViewController loads saved quotes from disk, or uses the default quotes if there weren’t any saved quotes.
  • The cellForRowAt method of QuotesViewController configures cells by hand, formatting quotes onto one line.
  • The addQuote() method manipulates the data model directly then presents editing UI.
  • The showRandomQuote() method decides which quote to show then shows it, all in one.
  • The finishedEditing() method updates or deletes quotes when a quote is edited.
  • The editActionsForRowAt method creates table view cell swipe actions and does more data model manipulation.
  • The saveQuotes() method writes the view controller’s quotes to disk.
  • Most of ShowQuoteViewController is dedicated to formatting quotes, which makes it hard to reuse that work.

We could split those up into two categories:

  • The view controllers blend data manipulation with user interface control – you can’t add a quote without also showing a new view controller, for example.
  • The view controllers are formatting their data in a scattershot way. How quotes format themselves should be a matter for the Quote struct.

There are more problems that we might look at in a later article – all the confused code flow for navigating view controllers is a prime target, for example – but the focus here is on making this app more testable.

Refactoring for testability

There are lots of tests we could write for this app, but right now the logic is so tightly tied to the view controllers that such tests would be complex.

For this article we’re going to write six tests:

  1. Does loading our initial quote collection work?
  2. Does choosing a random quote return something?
  3. Does quote formatting work in a consistent way?
  4. Can we add new quotes?
  5. Can we delete existing quote?
  6. Can we replace one quote with another?

In order to make that possible we’re going to do some code refactoring. This will partly mean moving some logic out of the view controllers and into the Quote struct, but mostly it will mean creating a new data model struct that will handle the logic of tracking quotes.

The goal here is to write a data model that doesn’t import UIKit, because doing so is almost always a sign that your data model is being polluted with user interface code. Instead, it will have simple end points we can call from our view controllers and tests to manipulate our app’s data.

Test 1: Does loading our initial quote collection work?

Although this app wasn’t design using test-driven development, we’re going to retcon it – we’re going to write failing tests, then refactor the existing code to make those tests pass.

First, we need a testing target. So, go to the File menu and choose New > Target. Scroll down until you see “iOS Unit Testing Bundle”, then press Next and Finish.

Open the new ParaphraseTests group, then select ParaphraseTests.swift inside there. This contains four example methods to help us get started: please keep setUp() and tearDown(), but delete the testExample() and testPerformanceExample() methods.

In place of those, we’re going to write our first failing test: does loading the initial collection of quotes work? Although the real app is going to save changes that are made to the quotes list, we want our data model to be in a pristine state for every test.

So, when creating the data model – which doesn’t exist yet! – we’ll tell it we’re in testing mode so that it will always load its quotes list from initial-quotes.json.

Start by adding @testable import Paraphrase to the top of ParaphraseTests.swift so that we can manipulate our main target, then add this test to the ParaphraseTests class:

func testLoadingInitialQuotes() {
    let model = QuotesModel(testing: true)
    XCTAssert(model.count == 12)

That will of course fail, but that’s the point of test-driven development – write tests that fail, then do just enough work to make them pass. We’re retconning it here so the process isn’t quite the same as you’ll see, but hopefully you can still follow along.

In this case that means making a new QuotesModel struct that will manage our app’s quotes. So, press Cmd+N to make a new file, choose Swift File, then name it QuotesModel.swift.

Please ensure you add this to your Paraphrase group and target, not to the testing group and target. That means changing the Group dropdown to be “Paraphrase” with the yellow folder icon next to it, and ensuring that “Paraphrase” and not “ParaphraseTests” is checked in the list of targets.

QuotesModel.swift contains a single import for Foundation, but to make our test pass it needs more:

  1. A quotes array that we can use to store our quotes.
  2. A count property that returns how many quotes are in the internal array.
  3. An initializer that accepts a testing parameter and loads data into the quotes array.

Here’s how the basic shell of QuotesModel should look:

struct QuotesModel {
    private var quotes = [Quote]()

    var count: Int {
        return quotes.count

    init(testing: Bool = false) {


I’ve made quotes private so that no other parts of the app can poke around in there directly. I also made the testing parameter default to false so that the rest of our app doesn’t have to worry about this special mode.

Although our test code will compile, it won’t pass because it expects to see the default 12 quotes in there and we haven’t added any loading code yet.

To get closer to the goal we need to take the loading code from QuotesViewController.swift. This is inside viewDidLoad(), and it’s everything from the // load our quote data comment down to the end of the method – please copy that to your clipboard, leaving it intact in QuotesViewController.swift, then paste it into the initializer in QuotesModel.swift.

At this point QuotesModel.swift should look like this:

import Foundation

struct QuotesModel {
    private var quotes = [Quote]()

    var count: Int {
        return quotes.count

    init(testing: Bool = false) {
        // load our quote data
        let defaults = UserDefaults.standard
        let quoteData : Data

        if let savedQuotes = "SavedQuotes") {
            // we have saved quotes; use them
  "Loading saved quotes")
            quoteData = savedQuotes
        } else {
            // no saved quotes; load the default initial quotes
  "No saved quotes")
            let path = Bundle.main.url(forResource: "initial-quotes", withExtension: "json")!
            quoteData = try! Data(contentsOf: path)

        let decoder = JSONDecoder()
        quotes = try! decoder.decode([Quote].self, from: quoteData)

However, even with that change there’s still a good chance your test won’t pass. You see, the test relies upon there being precisely 12 quotes in the system, but if you added or deleted quotes earlier that number will be different and your test will fail.

This is precisely why the testing parameter is being passed into the initializer: if that’s true we always want to load quotes from initial-quotes.json rather than reading saved quotes.

This is easy enough to fix. Find this line in the QuotesModel initializer:

if let savedQuotes = "SavedQuotes") {

Now change it to this:

if !testing, let savedQuotes = "SavedQuotes") {

So, we’ll only try reading saved quotes from disk if we aren’t in testing mode.

At this point you should be able to run the testLoadingInitialQuotes() test again and get a green checkmark – that’s one down.

Test 2: Does choosing a random quote return something?

The app has a “Random” button that chooses one random quote and displays it – a good example of how our view controllers mix logic and presentation. To help pull this apart, let’s write another failing test that reads a random quote.

Add this to ParaphraseTests.swift:

func testRandomQuote() {
    let model = QuotesModel(testing: true)

    guard let quote = model.random() else {

    XCTAssert( == "Eliot")

That won’t compile, and again that’s OK – that’s the point. However, you might be wondering how this test can work: how can we test that a random quote always has the author “Eliot”?

Well, one of the marvelous features of GameplayKit is its ability to generate predictable random numbers. By default you don’t want this because it would no longer be random, but it’s perfect for testing – the rest of your code can carry on using “random” numbers, but you can write tests that will reliably always work because the randomness is predictable.

So, we can use the testing parameter in our QuoteModel struct to create one of two randomizers: one that is truly random for general app operation, and one that is predictably random for testing.

Open QuotesModel.swift and import GameplayKit so we can draw upon its randomization system. Now give the QuotesModel struct this new property:

var randomSource: GKRandomSource?

GKRandomSource is the superclass of several different random number generators built into GameplayKit.

Next, add this code to the start of the initializer so our model either generates predictably random numbers or truly random numbers as appropriate:

if testing {
    randomSource = GKMersenneTwisterRandomSource(seed: 1)
} else {
    randomSource = GKMersenneTwisterRandomSource()

The seed: 1 parameter does the magic – it forces the Mersenne Twister algorithm to start in a specific state so that as we generate random numbers from it they will always come in the same sequence.

Note: In case you were curious, these random numbers are always the same given the same seed. This means you could have 10 different iPhones sharing the same seed, all generating the same “random” numbers. You could even have entirely different programs generating numbers that are all the same, as long as they all use a correct implementation of the Mersenne Twister algorithm.

Adding randomizers into QuoteModel haven’t made our test pass, but we’re almost there – the last step is to add a random() method that draws upon our random source to pick out a random quote. This will return an optional Quote because the array might be empty, but most of the time it will generate a new random number using our randomSource property and return that quote.

Add this to QuoteModel now:

func random() -> Quote? {
    guard !quotes.isEmpty else { return nil }

    let randomNumber = randomSource?.nextInt(upperBound: quotes.count) ?? 0
    return quotes[randomNumber]

With that change you should be able to run the testRandomQuote() test and see it pass – another one done.

Test 3: Does quote formatting work in a consistent way?

Quote formatting – the process of taking a Quote instance and converting it to a string that can be displayed nicely on-screen – is done in three places inside the current app:

  1. In the cellForRowAt method of QuotesViewController.swift quotes are collapsed down to a single line.
  2. In the viewDidLoad() method of ShowQuoteViewController.swift quotes are formatted into an attributed string.
  3. In the shareQuote() method of the same file quotes are shared as a single string including their author.

Unless you need something highly specific, I’m a big fan of models knowing how to format themselves. If you were using MVVM you might very well put such transformations into your view model, but in my experience that often leads to bloated view models that then need to be refactored further.

So, instead we’re going make our Quote struct know how to format its data in one of three ways: as a single-line string, as a multi-line string, and as an attributed string.

Note: Although attributed strings might seem more View-ish than Model-ish, they are really just models – they don’t present anything, and might just be used to read, write, or manipulate formatted text such as HTML. As a result, I have no qualms about putting the project’s current attributed string code into the Quote model.

First, let’s make a failing test. I’m not going to write tests for all three formatting types because that would be repetitive here, so let’s just write a test for the multi-line case. Add this to ParaphraseTests.swift now:

func testFormatting() {
    let model = QuotesModel(testing: true)
    let quote = model.quote(at: 0)

    let testString = "\"\(quote.text)\"\n   — \("
    XCTAssert(quote.multiLine == testString)

That won’t compile because of two problems:

  1. It’s trying to read a quote from the data model using a quote(at:) method that doesn’t exist.
  2. It’s trying to read a multiLine property of our quotes, which also doesn’t exist.

The quote(at:) method is trivial, so let’s start there. This will accept an integer index into the quotes array and return the respective Quote instance.

Add this to the QuotesModel struct now:

func quote(at position: Int) -> Quote {
    return quotes[position]

The second problem is almost a trivial: we need to implement a multiLine property in the Quote struct, and it should return the multi-line formatted version of the quote.

First, add an empty computed property to Quote, like this:

var multiLine: String {


Second, open ShowQuoteViewController.swift and find this line in its shareQuote() method:

let fullText = "\"\(quote.text)\"\n   — \("

I’d like you to copy everything after the = sign to your clipboard. Don’t cut it – i.e., don’t delete it from ShowQuoteViewController.swift – because I want you to leave the original code intact.

You should have "\"\(quote.text)\"\n — \(" on your Mac’s clipboard, so I want you to go back to Quote.swift and paste that inside the computed property we just added, then remove the quote. parts, like this:

var multiLine: String {
    return "\"\(text)\"\n   — \(author)"

That should be enough to make our test pass, so please try running it now. However, please keep in mind this is just one of three places where quotes are formatted – you should also create singleLine and attributedString computed properties by copying in the code from elsewhere in the project.

You’re welcome to do that yourself if you want, or just add these two to Quote.swift:

var singleLine: String {
    let formattedText = text.replacingOccurrences(of: "\n", with: " ")
    return "\(author): \(formattedText)"

var attributedString: NSAttributedString {
    // format the text and author of this quote
    var textAttributes = [NSAttributedStringKey: Any]()
    var authorAttributes = [NSAttributedStringKey: Any]()

    if let quoteFont = UIFont(name: "Georgia", size: 24) {
        let fontMetrics = UIFontMetrics(forTextStyle: .headline)
        textAttributes[.font] = fontMetrics.scaledFont(for: quoteFont)

    if let authorFont = UIFont(name: "Georgia-Italic", size: 16) {
        let fontMetrics = UIFontMetrics(forTextStyle: .body)
        authorAttributes[.font] = fontMetrics.scaledFont(for: authorFont)

    let finishedQuote = NSMutableAttributedString(string: text, attributes: textAttributes)
    let authorString = NSAttributedString(string: "\n\n\(author)", attributes: authorAttributes)

    return finishedQuote

With those changes all our tests are working again, so we’re making progress!

Test 4: Can we add new quotes?

It’s time for another failing test – please add this to ParaphraseTests.swift:

func testAddingQuote() {
    var model = QuotesModel(testing: true)
    let quoteCount = model.count

    let newQuote = Quote(author: "Paul Hudson", text: "Programming is an art. Don't spend all your time sharpening your pencil when you should be drawing.")

    XCTAssert(model.count == quoteCount + 1)

That fails because our QuotesModel struct doesn’t have an add() method, so let’s go ahead and fix that.

Adding a quote to our model is simple enough because the least we need to do is append the new quote to the quotes array. So, add this to QuotesModel now:

mutating func add(_ quote: Quote) {

That’s literally all it takes – if you run the test again it should pass.

Test 5: Can we delete an existing quote?

This is another easy test to write, because we’ll create an instance of our data model, read its count, remove an item, then check that its count has gone down by one.

Add this test now:

func testRemovingQuote() {
    var model = QuotesModel(testing: true)
    let quoteCount = model.count

    model.remove(at: 0)
    XCTAssert(model.count == quoteCount - 1)

We haven’t written a remove(at:) method yet, but it’s just as easy as add() was. Add this to QuotesModel.swift now:

mutating func remove(at index: Int) {
    quotes.remove(at: index)

Just like adding quotes, that literally just passes the call straight onto the internal array. This is important, though, because we’re encapsulation the implementation details – we might rewrite QuotesModel to use Core Data or CloudKit one day, and by hiding all its internals behind methods we’re able to stay flexible in the future.

Try running the testRemovingQuote() test again, but you should find it passes with no problem.

Test 6: Can we replace one quote with another?

The last test is more complex, because there is extra logic here. We’ll look at that logic in a moment, but first we need to write some failing tests – it’s plural this time because replacing quotes has two different behaviors depending on what the new quote is.

First, if we replace one quote with another then we should be able to read the replaced quote back out from the data model and see it has the correct data. Add this test now:

func testReplacingQuote() {
    var model = QuotesModel(testing: true)

    let newQuote = Quote(author: "Ted Logan", text: "All we are is dust in the wind, dude.")
    model.replace(index: 0, with: newQuote)

    let testQuote = model.quote(at: 0)
    XCTAssert( == "Ted Logan")

Second, replacing a quote with an empty quote – no author or quote text – should delete the existing quote. This will reduce the overall quote count by one, so we can compare the new count against the previous one.

Please add this second test:

func testReplacingEmptyQuote() {
    var model = QuotesModel(testing: true)
    let previousCount = model.count

    let newQuote = Quote(author: "", text: "")
    model.replace(index: 0, with: newQuote)

    XCTAssert(model.count == previousCount - 1)

Both of those rely on a method called replace(index:with:)`, which is where the extra logic comes in – should we replace the quote or delete the existing one?

Open QuotesViewController.swift and look for its finishedEditing() method. This gets called when the editing view controller is being dismissed so that we can update the quotes table. You’ll see it contains this logic:

if && quote.text.isEmpty {
    // if no text was entered just delete the quote"Removing empty quote")
    quotes.remove(at: selected)
} else {
    // replace our existing quote with this new one then save"Replacing quote at index \(selected)")
    quotes[selected] = quote

That’s the part responsible for deciding whether to replace or delete the selected quote. I’d like you to select all that text and copy it to your clipboard. Again, please don’t cut the code – leave the original intact.

Now open QuotesModel.swift and write this method stub:

mutating func replace(index: Int, with quote: Quote) {

I’d like you to paste your copied code inside that method. You’ll get four compile errors, but they are easily solved:

  1. Change quotes.remove(at: selected) to remove(at: index).
  2. Change quotes[selected] = quote to be quotes[index] = quote.
  3. Change the string interpolation of \(selected) to be \(index).
  4. Delete self.saveQuotes() entirely.

The end result should look like this:

mutating func replace(index: Int, with quote: Quote) {
    if && quote.text.isEmpty {
        // if no text was entered just delete the quote"Removing empty quote")
        remove(at: index)
    } else {
        // replace our existing quote with this new one then save"Replacing quote at index \(index)")
        quotes[index] = quote

You should now be able to run both of the new tests and see they pass – great!

Cleaning up

Retconning TDD like we’ve done has made our tests pass, but our app isn’t any better. In fact, it’s arguably worse because the tests give us a false sense of security: everything appears to be passing just fine, but because our view controllers don’t use all the refactored code we wrote things could break massively without causing our tests to fail.

So, the final part of our refactoring is to fix up the rest of the app to use our new QuotesModel file. Let’s start with QuotesViewController.swift, as that probably has the most changes.

First, find this property:

var quotes = [Quote]()

And replace it with this:

var model = QuotesModel()

That will cause errors in viewDidLoad() where the view controller previously tried to load its own data. That code can all go, so please delete everything in viewDidLoad() after the // load our quote data comment.

The next error is in the numberOfRowsInSection method, which previously returned quotes.count. Please change that to model.count.

Moving on, you’ll see an error in cellForRowAt because it’s trying to read the quotes array. We want that to use our QuotesModel object instead, but we can take the opportunity to upgrade this code to use the new singleLine property of our Quote struct – it’s much nicer than doing formatting inside this method.

So, find this code in cellForRowAt:

let quote = quotes[indexPath.row]
let formattedText = quote.text.replacingOccurrences(of: "\n", with: " ")
let cellText = "\( \(formattedText)"

cell.textLabel?.text = cellText

And replace it with this:

let quote = model.quote(at: indexPath.row).singleLine
cell.textLabel?.text = quote

The next error is inside didSelectRowAt, which again tries to read directly from the old quotes array.

Replace this line:

let selectedQuote = quotes[indexPath.row]

With this:

let selectedQuote = model.quote(at: indexPath.row)

It gives the same end result, except now it uses our accessor method.

The next error is in addQuote(), where these two lines are bad:

selectedRow = quotes.count - 1

They just need to use the new add() method and count property of our model, like this:

selectedRow = model.count - 1

There are three errors inside the showRandomQuote() method, on these lines:

guard !quotes.isEmpty else { return }
let randomNumber = GKRandomSource.sharedRandom().nextInt(upperBound: quotes.count)
let selectedQuote = quotes[randomNumber]

Just like the previous lines, these poke around in the old quotes array and that’s no longer allowed. Fortunately we have a shiny new random() method that will return a quote if one is possible, so you can replace those three lines with this:

guard let selectedQuote = model.random() else { return }

There are two errors inside finishedEditing(), both in the logic to handle replacing things in our data model. Here’s the existing code:

if && quote.text.isEmpty {
    // if no text was entered just delete the quote"Removing empty quote")
    quotes.remove(at: selected)
} else {
    // replace our existing quote with this new one then save"Replacing quote at index \(selected)")
    quotes[selected] = quote

I’d like you to replace all of that with this one new line:

model.replace(index: selected, with: quote)

Refactoring is awesome, isn’t it?

Moving on, there are two errors inside the editActionsForRowAt method, both because we’re trying to access the old quotes array directly.

The first error is here:

self.quotes.remove(at: indexPath.row)

We can replace that with a call to our new remove(at:) method:

self.model.remove(at: indexPath.row)

The second error is here:

let quote = self.quotes[indexPath.row]

That should be replaced with another call to our quote(at:) method, like this:

let quote = self.model.quote(at: indexPath.row)

Finally, there’s an error in the saveQuotes() method. But how should that be fixed? We don’t want to reach into the QuotesModel struct to write its quotes array, because that undoes some of our work of encapsulating implementation details.

Instead, what we’re going to do is move the saveQuotes() method inside the QuotesModel struct. So, please select saveQuotes() inside QuotesViewController.swift and cut it to your clipboard – delete it from the original.

Now go to QuotesModel.swift and paste the method in there instead. There’s no need to call it saveQuotes() any more because this is part of our quotes model, so rename it to save() instead.

While that has fixed the error we had, it has introduced other errors. First, there’s a call to self.saveQuotes() in QuotesViewController.swift’s editActionsForRowAt method – the fix for that is just to delete that line.

However, the bigger problem is that we no longer save the quotes as they are changed, and to fix that we need to add three calls to the new save() method inside QuotesModel.swift.

So, open QuotesModel.swift and add calls to save() in these three places:

  1. At the end of the add() method.
  2. At the end of the remove(at:) method.
  3. Directly below quotes[index] = quote in the replace(index:with:) method.

With those calls in place the data model will automatically write its data to disk whenever it changes, which is much neater.

Before we’re done, there are two smaller improvements we should make just to make sure our code is as DRY as possible.

If you recall, we added multiLine and attributedString properties to our Quote type to handle those formatting cases. We aren’t using them yet, though, so before we’re done we should do so.

Open ShowQuoteViewController.swift and look for the comment // format the text and author of this quote in its viewDidLoad() method. I’d like you to delete everything in the method below that line and replace it with this:

quoteLabel.attributedText = quote.attributedString

Finally, find this line in the shareQuote() method:

let fullText = "\"\(quote.text)\"\n   — \("

Now replace it with this:

let fullText = quote.multiLine

Where next?

This was the first part of a short series on upgrading apps to take advantage of modern infrastructure. You’ve seen how even fairly poor apps can be refactored to be more testable, and even though this app still has many flaws it does now have a simpler architecture as well as the beginnings of good tests. If you'd like to read more about testing, project 39 of Hacking with Swift teaches XCTest – you should start there.

In the following articles we’ll move beyond Xcode and look at other ways computer automation can help us write better code – stay tuned!


Buy Pro Swift Buy Swift Design Patterns Buy Practical iOS 11 Buy Swift Coding Challenges Buy Server-Side Swift (Vapor Edition) Buy Server-Side Swift (Kitura Edition) Buy Hacking with macOS Buy Advanced iOS Volume One Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with Swift Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

About the author

Paul Hudson is the creator of Hacking with Swift, the most comprehensive series of Swift books in the world. He's also the editor of Swift Developer News, the maintainer of the Swift Knowledge Base, and Mario Kart world champion. OK, so that last part isn't true. If you're curious you can learn more here.

Was this page useful? Let me know!

Click here to visit the Hacking with Swift store >>