Test-driven development in iOS – Part 1

“Could you update the design for that button only on the settings screen? Don’t think too much about it though, it should have been released yesterday.”

I’m sure it sounds familiar. There are times in many projects’ lifecycle when pressing changes are necessary, but thinking too much about the details is “not that important”. This mostly ends up in writing spaghetti code and the outcome will be bugs in your production code. Unless you write good enough tests, of course, to keep you safe from them.

What is TDD?

Alt Text

Test-driven development is all over the place. The basic idea is you write tests first, before any actual code. Tests will fail at first, so you need to write just enough code to make them pass. Then you can refactor your code without changing the behavior so your tests still pass.

Many argue it’s too bureaucratic, which is somewhat true. At first, it definitely slows down the implementation process, so make sure to dedicate some time for the learning phase.

Once that’s over though, adding features will become natural and you will be able to enjoy the beautiful side-effects. Your code will become self-documenting as reading your test-cases should describe exactly what your supporting code does.

Your code coverage will be improved, so issues like the one mentioned above will let you sleep better in the evening. Similarly, implementing bigger refactorings will also get easier thanks to the better coverage.

Getting started

The source-code for the article is available on Github.

The first thing to do is coming up with a concept. Without writing any code, just thinking through what we’re about to build. Our showcase project is a diary. The basic features are the following:

  • New entries can be added
  • Existing entries can be updated
  • Existing entries can be removed

Alt Text

Without implementing anything, think through what will we need based on our concept so far (since the intention of this post is to give you an introduction, we’ll not overcomplicate the main app design here):

  • We’ll be working with some kind of entry objects, that will be stored in some data source
  • We’ll for sure have two screens: the entry list and the entry detail screen to edit entries
  • It would be great to have a factory that prepares our screens and a router to handle the app navigation

Writing tests

Once we know what we want and we have an overview in our head, we can start adding our tests and their minimum supporting code.

1. Testing the data source

Let’s add a test to check if our data source is empty by default. Let’s expose the entries already sorted to make sure the newest entry will always be the first.

func test_entries_emptyByDefault() {
    let dataSource = DiaryDataSource()
    XCTAssertEqual(dataSource.sortedEntries.count, 0)
}

When you run the test, it will of course fail. The minimal required code to fix this could be this.

class DiaryDataSource {

    var sortedEntries: [AnyObject] {
        []
    }
}

Let’s add a test to see if saving entries works. For that let’s introduce an actual model first to describe our entries instead of AnyObject.

struct Entry {

    // For the scope of the demo project, `date` behaves as the unique key
    let date: Date
    var text: String
}

We can add the test now.

func test_entries_addsEntry() {
    let testDate1 = Date()
    let testText1 = "First entry text"
    let testEntry1 = Entry(date: testDate1, text: testText1)

    let dataSource = DiaryDataSource()
    dataSource.save(entry: testEntry1)

    XCTAssertEqual(dataSource.sortedEntries.count, 1)
}

We have a failing test, great! Let’s implement saving in our data source.

class DiaryDataSource {

    fileprivate var entries: [Entry] = []

    var sortedEntries: [Entry] {
        entries
    }

    func save(entry: Entry) {
        entries.removeAll(where: { $0.date == entry.date })
        entries.append(entry)
    }
}

Let’s create another test to make sure our entry is inserted only once if we try to insert it multiple times.

func test_entries_addsEntry_onlyOnce() {
    let testDate1 = Date()
    let testText1 = "First entry text"
    let testEntry1 = Entry(date: testDate1, text: testText1)

    let dataSource = DiaryDataSource()
    dataSource.save(entry: testEntry1)
    dataSource.save(entry: testEntry1)
    dataSource.save(entry: testEntry1)

    XCTAssertEqual(dataSource.sortedEntries.count, 1)
}

Perfect, our tests succeeded. If we check it closer, our second test actually also tests our single insertion use-case, so we can delete that one.

We can now add the test to check if our sorting works.

func test_entries_addsEntry_keepsOrder() {
    let dataSource = DiaryDataSource()
    
    let testDate1 = Date()
    var testDate2: Date { testDate1.addingTimeInterval(3600 * 1) }
    var testDate3: Date { testDate1.addingTimeInterval(3600 * 2) }

    let testText1 = "First entry text"
    let testText2 = "Second entry text"
    let testText3 = "Third entry text"

    let testEntry1 = Entry(date: testDate1, text: testText1)
    let testEntry2 = Entry(date: testDate2, text: testText2)
    let testEntry3 = Entry(date: testDate3, text: testText3)

    dataSource.save(entry: testEntry2)
    dataSource.save(entry: testEntry3)
    dataSource.save(entry: testEntry1)

    let entries = dataSource.sortedEntries
    XCTAssertEqual(entries.count, 3)
    XCTAssertEqual(entries[0].date, testDate3)
    XCTAssertEqual(entries[0].text, testText3)
    XCTAssertEqual(entries[1].date, testDate2)
    XCTAssertEqual(entries[1].text, testText2)
    XCTAssertEqual(entries[2].date, testDate1)
    XCTAssertEqual(entries[2].text, testText1)
}

Another failing test, great! The reason is that we didn’t implement sorting, yet, let’s add that now.

class DiaryDataSource {

    fileprivate var entries: [Entry] = []

    var sortedEntries: [Entry] {
        entries.sorted { $0.date > $1.date }
    }

    func save(entry: Entry) {
        entries.removeAll(where: { $0.date == entry.date })
        entries.append(entry)
    }
}

The test succeeds now nicely, but there’s a lot of boilerplate code in it. To shrink the test size, let’s move the test data to a common XCTestCase class and inherit our tests from that one.

class BaseTestCase: XCTestCase {

    // MARK: Test data

    let testDate1 = Date()
    var testDate2: Date { testDate1.addingTimeInterval(3600 * 1) }
    var testDate3: Date { testDate1.addingTimeInterval(3600 * 2) }

    let testText1 = "First entry text. This one is very long to make sure we also test the case when the entry's message is too long for the cell, so it gets trimmed"
    let testText2 = "Second entry text"
    let testText3 = "Third entry text"

    lazy var testEntry1: Entry = { Entry(date: testDate1, text: testText1) }()
    lazy var testEntry2: Entry = { Entry(date: testDate2, text: testText2) }()
    lazy var testEntry3: Entry = { Entry(date: testDate3, text: testText3) }()

    let dataSource = DiaryDataSource()
}

Let’s refactor our tests so far, they look a lot cleaner now.

class DiaryDataSourceTests: BaseTestCase {

    func test_entries_emptyByDefault() {
        XCTAssertEqual(dataSource.sortedEntries.count, 0)
    }

    func test_entries_addsEntry_onlyOnce() {
        dataSource.save(entry: testEntry1)
        dataSource.save(entry: testEntry1)
        dataSource.save(entry: testEntry1)

        XCTAssertEqual(dataSource.sortedEntries.count, 1)
    }

    func test_entries_addsEntry_keepsOrder() {
        dataSource.save(entry: testEntry2)
        dataSource.save(entry: testEntry3)
        dataSource.save(entry: testEntry1)

        let entries = dataSource.sortedEntries
        XCTAssertEqual(entries.count, 3)
        XCTAssertEqual(entries[0].date, testDate3)
        XCTAssertEqual(entries[0].text, testText3)
        XCTAssertEqual(entries[1].date, testDate2)
        XCTAssertEqual(entries[1].text, testText2)
        XCTAssertEqual(entries[2].date, testDate1)
        XCTAssertEqual(entries[2].text, testText1)
    }
}

Let’s move on now to removing entries from the store. The first test should check if the store gets empty after removing the only entry from it.

func test_entries_removesAll() {
    dataSource.save(entry: testEntry1)
    dataSource.remove(entry: testEntry1)

    XCTAssertEqual(dataSource.sortedEntries.count, 0)
}

We’re happy, it’s failing, let’s add removal to our data source as well.

// Add this to DiaryDataSource.swift
func remove(entry: Entry) {
    entries.removeAll(where: { $0.date == entry.date })
}

Let’s add another one to make sure the right entry is being removed from the store.

func test_entries_removesFirst() {
    dataSource.save(entry: testEntry1)
    dataSource.save(entry: testEntry2)
    dataSource.remove(entry: testEntry1)

    let entries = dataSource.sortedEntries
    XCTAssertEqual(entries.count, 1)
    XCTAssertEqual(entries[0].date, testDate2)
    XCTAssertEqual(entries[0].text, testText2)
}

This doesn’t fail, so we might as well remove our previous test as it’s covered by this one. Let’s add a test case to cover updating entries as well.

func test_entries_updatesEntry() {
    var entry = testEntry1
    dataSource.save(entry: entry)

    entry.text = testUpdatedText
    dataSource.save(entry: entry)
    let entries = dataSource.sortedEntries

    XCTAssertEqual(entries.count, 1)
    XCTAssertEqual(entries[0].date, testDate1)
    XCTAssertEqual(entries[0].text, testUpdatedText)
}

Let’s add a new test property in our base test case.

let testUpdatedText = "Updated entry text"

Perfect, we’re done with our data source, it’s time to test the router.

2. Testing the router

The first test should cover if the router navigates to the diary list controller properly.

func test_displaysDiaryListController() {
    router.displayDiaryList()

    XCTAssertEqual(navigationController.viewControllers.count, 1)
    XCTAssertEqual(navigationController.viewControllers.first, diaryListViewController)
}

We’ll need some helpers for this. First, I’d like to mock UIKit‘s UINavigationController to get rid of its animations. Of course, by this, we’re changing default behavior, so feel free to skip this step.

class MockNavigationController: UINavigationController {

    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        super.pushViewController(viewController, animated: false)
    }

    override func popViewController(animated: Bool) -> UIViewController? {
        super.popViewController(animated: false)
    }
}

Then we’ll need an empty router to get rid of the errors.

class DiaryRouter {

    fileprivate let navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func displayDiaryList() {}
}

We can add some test properties to our base test case to silence all errors now.

let navigationController = MockNavigationController()
let diaryListViewController = UIViewController()

lazy var router: DiaryRouter = {
    DiaryRouter(navigationController: navigationController)
}()

We have a failing test, perfect. Let’s introduce a view controller factory interface and create an app-specific instance from it. Then we can inject this to our router, so it can reuse the view controllers the factory is creating.

protocol ViewControllerFactory {

    var dataSource: DiaryDataSource { get }

    func diaryListViewController(router: DiaryRouter) -> UIViewController
}
final class DiaryViewControllerFactory: ViewControllerFactory {

    let dataSource: DiaryDataSource

    init(dataSource: DiaryDataSource) {
        self.dataSource = dataSource
    }

    func diaryListViewController(router: DiaryRouter) -> UIViewController {
        return UIViewController()
    }
}

We can then extend our router as well.

final class DiaryRouter {

    fileprivate let navigationController: UINavigationController
    fileprivate let factory: ViewControllerFactory

    init(navigationController: UINavigationController, factory: ViewControllerFactory) {
        self.navigationController = navigationController
        self.factory = factory
    }

    func displayDiaryList() {
        let controller = factory.diaryListViewController(router: self)
        navigationController.viewControllers = [controller]
    }
}

Let’s then mock our factory in the test target.

class MockViewControllerFactory: ViewControllerFactory {

    let dataSource: DiaryDataSource

    init(dataSource: DiaryDataSource) {
        self.dataSource = dataSource
    }

    fileprivate var stubbedDiary: UIViewController? = nil

    func stub(diaryListWith viewController: UIViewController) {
        stubbedDiary = viewController
    }

    func diaryListViewController(router: DiaryRouter) -> UIViewController {
        guard let stubbedDiary = stubbedDiary else {
            fatalError("View controllers need to be stubbed for the tests")
        }
        return stubbedDiary
    }
}

Lastly, we can stub our view controller and update our test properties in our base test case.

lazy var factory: MockViewControllerFactory = {
    MockViewControllerFactory(dataSource: dataSource)
}()

lazy var router: DiaryRouter = {
    DiaryRouter(navigationController: navigationController, factory: factory)
}()

override func setUp() {
    // Force-loading the views
    let _ = diaryListViewController.view

    // Stubbing the view controllers
    factory.stub(diaryListWith: diaryListViewController)
}

Great, all tests are green now. Let’s add another test to make sure our router navigates to the list only once, then we can get rid of the first test since it will be deprecated by it.

func test_displaysDiaryListController_onlyOnce() {
    router.displayDiaryList()
    router.displayDiaryList()
    router.displayDiaryList()

    XCTAssertEqual(navigationController.viewControllers.count, 1)
    XCTAssertEqual(navigationController.viewControllers.first, diaryListViewController)
}

Let’s add a test to verify the entry detail screen gets displayed when the user taps the add button. We will add a bar button item to the top right on the list. Our excpectation is, that the router will present the new entry screen once we tap on it.

func test_displaysNewEntryController_fromList() {
    router.displayDiaryList()
    diaryListViewController.navigationItem.rightBarButtonItem?.simulateTap()

    XCTAssertEqual(navigationController.viewControllers.count, 2)
    XCTAssertEqual(navigationController.viewControllers.first, diaryListViewController)
    XCTAssertEqual(navigationController.viewControllers.last, newEntryViewController)
}

We can add a helper extension in the test target to simulate the tap with a nicer api.

extension UIBarButtonItem {

    func simulateTap() {
        guard let action = action else { return }
        target?.performSelector(onMainThread: action, with: nil, waitUntilDone: true)
    }
}

Let’s update our factory to support creating the new entry screen.

protocol ViewControllerFactory {

    var dataSource: DiaryDataSource { get }

    func diaryListViewController(router: DiaryRouter) -> UIViewController
    func newEntryViewController(router: DiaryRouter) -> UIViewController
}
class MockViewControllerFactory: ViewControllerFactory {

    let dataSource: DiaryDataSource

    init(dataSource: DiaryDataSource) {
        self.dataSource = dataSource
    }

    fileprivate var stubbedDiary: UIViewController? = nil
    fileprivate var stubbedNewEntry: UIViewController? = nil

    func stub(diaryListWith viewController: UIViewController) {
        stubbedDiary = viewController
    }

    func stub(newEntryWith viewController: UIViewController) {
        stubbedNewEntry = viewController
    }

    func diaryListViewController(router: DiaryRouter) -> UIViewController {
        guard let stubbedDiary = stubbedDiary else {
            fatalError("View controllers need to be stubbed for the tests")
        }
        return stubbedDiary
    }

    func newEntryViewController(router: DiaryRouter) -> UIViewController {
        return stubbedNewEntry ?? UIViewController()
    }
}

Then update our base test case with the new test properties.

let newEntryViewController = UIViewController()

override func setUp() {
    // Force-loading the views
    let _ = diaryListViewController.view
    let _ = newEntryViewController.view

    // Stubbing the view controllers
    factory.stub(diaryListWith: diaryListViewController)
    factory.stub(newEntryWith: newEntryViewController)
}

Great, we have a failing test again! Let’s add a basic dairy list view controller and update our mocked factory in the test target to support it first.

typealias AddNewEntryCallback = () -> Void

class DiaryListViewController: UIViewController {

    fileprivate let addNewEntryCallback: AddNewEntryCallback

    init(addNewEntryCallback: @escaping AddNewEntryCallback) {
        self.addNewEntryCallback = addNewEntryCallback

        super.init(nibName: String(describing: DiaryListViewController.self), bundle: nil)
    }

    required init?(coder: NSCoder) {
        print("init(coder:) has not been implemented")
        return nil
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(_:)))
    }

    @objc func addButtonTapped(_ sender: Any) {
        addNewEntryCallback()
    }
}
protocol ViewControllerFactory {

    var dataSource: DiaryDataSource { get }

    func diaryListViewController(router: DiaryRouter) -> DiaryListViewController
    func newEntryViewController(router: DiaryRouter) -> UIViewController
}
class MockViewControllerFactory: ViewControllerFactory {
    let dataSource: DiaryDataSource

    init(dataSource: DiaryDataSource) {
        self.dataSource = dataSource
    }

    fileprivate var stubbedDiary: DiaryListViewController? = nil
    fileprivate var stubbedNewEntry: UIViewController? = nil

    func stub(diaryListWith viewController: DiaryListViewController) {
        stubbedDiary = viewController
    }

    func stub(newEntryWith viewController: UIViewController) {
        stubbedNewEntry = viewController
    }

    func diaryListViewController(router: DiaryRouter) -> DiaryListViewController {
        guard let stubbedDiary = stubbedDiary else {
            fatalError("View controllers need to be stubbed for the tests")
        }
        return stubbedDiary
    }

    func newEntryViewController(router: DiaryRouter) -> UIViewController {
        return UIViewController()
    }
}

Also, change the list’s test property in the base test case.

lazy var diaryListViewController: DiaryListViewController = {
    DiaryListViewController {}
}()

Our test still fails, but the errors are gone now. Now we need to connect the router to actually navigate to the entry screen. Let’s add the following to the DiaryRouter.

func displayNewEntry() {
    let controller = factory.newEntryViewController(router: self)
    navigationController.pushViewController(controller, animated: true)
}

Perfect, our test succeeds now. At this point you should get the idea of how the “red-green-refactor” process works in detail. In the next part we will discuss the follow-up steps on a higher-level.

Don’t forget, the whole demo project with all test cases is available on Github.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.