Test-driven development in iOS

ishouldhaveknown

Tamas Dancsi

Posted on March 19, 2020

Test-driven development in iOS

"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)
}
Enter fullscreen mode Exit fullscreen mode

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] {
        []
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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

let testUpdatedText = "Updated entry text"
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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() {}
}
Enter fullscreen mode Exit fullscreen mode

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)
}()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
final class DiaryViewControllerFactory: ViewControllerFactory {

    let dataSource: DiaryDataSource

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

    func diaryListViewController(router: DiaryRouter) -> UIViewController {
        return UIViewController()
    }
}
Enter fullscreen mode Exit fullscreen mode

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]
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode
protocol ViewControllerFactory {

    var dataSource: DiaryDataSource { get }

    func diaryListViewController(router: DiaryRouter) -> DiaryListViewController
    func newEntryViewController(router: DiaryRouter) -> UIViewController
}
Enter fullscreen mode Exit fullscreen mode
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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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

lazy var diaryListViewController: DiaryListViewController = {
    DiaryListViewController {}
}()
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Perfect, our test succeeds now. I think at this point you get the idea of how the "red-green-refactor" process works in detail. From now on the post will only discuss the follow-up steps on a higher-level.

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

The next step is to test if the router can navigate to the entry screen of a selected entry.

func test_displaysEntryDetailsController() {
    router.displayDiaryList()
    router.displayEntryDetail(for: testEntry1)

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

func test_displaysDiaryListController_afterEntryDetail() {
    router.displayDiaryList()
    router.displayEntryDetail(for: testEntry1)
    router.displayDiaryList()

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

func test_displaysEntryDetailsController_onlyOne() {
    router.displayDiaryList()
    router.displayEntryDetail(for: testEntry1)
    router.displayEntryDetail(for: testEntry2)

    XCTAssertEqual(navigationController.viewControllers.count, 2)
    XCTAssertEqual(navigationController.viewControllers.first, diaryListViewController)
    XCTAssertEqual(navigationController.viewControllers.last, entryDetailViewController2)
}
Enter fullscreen mode Exit fullscreen mode

After extending our router to support this, we'll get something like this.

final class DiaryRouter: DiaryRouting {

    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]
    }

    func displayNewEntry() {
        resetToDiaryList()

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

    func displayEntryDetail(for entry: Entry) {
        resetToDiaryList()

        let controller = factory.entryDetailViewController(entry: entry, router: self)
        navigationController.pushViewController(controller, animated: true)
    }

    // MARK: Helpers

    fileprivate func resetToDiaryList() {
        if navigationController.viewControllers.count != 1 { displayDiaryList() }
    }
}
Enter fullscreen mode Exit fullscreen mode

And our factory's interface looks like the following. I've also added some documentation so future developers will understand immediately what it does.

protocol ViewControllerFactory {

    /// In our example the factory is the source of the data
    var dataSource: DiaryDataSource { get }

    /// Creates an entry list view controller
    /// - Parameters:
    ///   - router: Passed reference to the router
    func diaryListViewController(router: DiaryRouter) -> DiaryListViewController

    /// Creates a new entry view controller
    /// - Parameter router: Passed reference to the router
    func newEntryViewController(router: DiaryRouter) -> EntryDetailViewController

    /// Creates an entry detail view controller with the given entry
    /// - Parameters:
    ///   - router: Passed reference to the router
    func entryDetailViewController(entry: Entry,
                                   router: DiaryRouter) -> EntryDetailViewController
}
Enter fullscreen mode Exit fullscreen mode

3. Testing the UI: diary list screen

It's always challenging to test the UI from code. When you initiate a view controller, it's important to force-load its view if you want to test actual views being rendered. The easist way is to do it like this. SUT stands for subject under testing.

fileprivate func makeSUT(entries: [Entry],
                          entrySelectionCallback: @escaping EntrySelectionCallback = { _ in },
                          entryRemovalCallback: @escaping EntrySelectionCallback = { _ in }) -> DiaryListViewController {

    let controller = DiaryListViewController(entries: entries,
                                              entrySelectionCallback: entrySelectionCallback,
                                              entryRemovalCallback: entryRemovalCallback,
                                              addNewEntryCallback: {})
    let _ = controller.view
    return controller
}
Enter fullscreen mode Exit fullscreen mode

The first step is to add the easier tests for checking titles, init methods and so on.

func test_required_initWithCoder() {
    XCTAssertNil(DiaryListViewController(coder: NSCoder()))
}

func test_viewDidLoad_rendersTitle() {
    XCTAssertEqual(makeSUT(entries: []).title, "Diary")
}
Enter fullscreen mode Exit fullscreen mode

Then we can test if the entry list table view gets rendered correctly. Please find the helper extensions for the table view in the source code on Github.

func test_viewDidLoad_rendersEntries() {
    XCTAssertEqual(makeSUT(entries: []).entryList.numberOfRows(inSection: 0), 0)
    XCTAssertEqual(makeSUT(entries: [testEntry1]).entryList.numberOfRows(inSection: 0), 1)
    XCTAssertEqual(makeSUT(entries: [testEntry1, testEntry2]).entryList.numberOfRows(inSection: 0), 2)
}

func test_viewDidLoad_rendersEntryTexts() {
    XCTAssertEqual(makeSUT(entries: [testEntry1]).entryList.title(at: 0), testEntry1.text.truncate(length: 80))
    XCTAssertEqual(makeSUT(entries: [testEntry1, testEntry2]).entryList.title(at: 1), testEntry2.text.truncate(length: 80))
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we need to check if the passed callbacks get called at the right time.

func test_entrySelection_callsEntrySelectedCallback() {
    var selectedEntry: Entry? = nil
    let sut = makeSUT(entries: [testEntry1], entrySelectionCallback: { entry in
        selectedEntry = entry
    })

    sut.entryList.select(row: 0)
    XCTAssertEqual(selectedEntry, testEntry1)
}

func test_entryRemoval_callsEntryRemovalCallback() {
    var selectedEntry: Entry? = nil
    let sut = makeSUT(entries: [testEntry1], entryRemovalCallback: { entry in
        selectedEntry = entry
    })

    sut.entryList.remove(row: 0)
    XCTAssertEqual(selectedEntry, testEntry1)
}
Enter fullscreen mode Exit fullscreen mode

The minimum supporting code, that makes all tests pass is not that long at the end.

import UIKit

typealias EntrySelectionCallback = (Entry) -> Void
typealias AddNewEntryCallback = () -> Void

class DiaryListViewController: UIViewController {

    fileprivate(set) var entries: [Entry]
    fileprivate let entrySelectionCallback: EntrySelectionCallback
    fileprivate let entryRemovalCallback: EntrySelectionCallback
    fileprivate let addNewEntryCallback: AddNewEntryCallback

    @IBOutlet weak var entryList: UITableView!
    fileprivate let reuseIdentifier = "Cell"

    init(entries: [Entry],
         entrySelectionCallback: @escaping EntrySelectionCallback,
         entryRemovalCallback: @escaping EntrySelectionCallback,
         addNewEntryCallback: @escaping AddNewEntryCallback) {

        self.entries = entries
        self.entrySelectionCallback = entrySelectionCallback
        self.entryRemovalCallback = entryRemovalCallback
        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()
        setupNavigationBar()
        setupEntryList()
    }
}

// MARK: Navigation bar

extension DiaryListViewController {

    fileprivate func setupNavigationBar() {
        title = "Diary"
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(_:)))
    }

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

// MARK: Entry list

extension DiaryListViewController: UITableViewDelegate, UITableViewDataSource {

    fileprivate func setupEntryList() {
        entryList.register(UITableViewCell.self, forCellReuseIdentifier: reuseIdentifier)
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return entries.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .subtitle, reuseIdentifier: reuseIdentifier)
        let entry = entries[indexPath.row]

        cell.textLabel?.text = entry.text.truncate(length: 80)
        cell.textLabel?.lineBreakMode = .byWordWrapping
        cell.textLabel?.numberOfLines = 0
        cell.detailTextLabel?.text = entry.date.dateString()

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        entryList.deselectRow(at: indexPath, animated: true)
        entrySelectionCallback(entries[indexPath.row])
    }

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            entryRemovalCallback(entries[indexPath.row])
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Testing the UI: entry detail screen

The approach is very similar. First, let's test the init methods and the navigation bar.

func test_required_initWithCoder() {
    XCTAssertNil(EntryDetailViewController(coder: NSCoder()))
}

func test_viewDidLoad_rendersTitle() {
    XCTAssertEqual(makeSUT().title, Date().dateString())
    XCTAssertEqual(makeSUT(entry: testEntry1).title, testEntry1.date.dateString())
}

func test_viewDidLoad_rendersSaveButton() {
    XCTAssertEqual(makeSUT().navigationItem.rightBarButtonItem?.title, "Save")
    XCTAssertEqual(makeSUT(entry: testEntry1).navigationItem.rightBarButtonItem?.title, "Update")
}
Enter fullscreen mode Exit fullscreen mode

Then let's test the content, which in this case is a text view.

func test_viewDidLoad_insertMode_rendersEmptyTextView() {
    XCTAssertEqual(makeSUT().textView.text, "")
}

func test_viewDidLoad_editMode_rendersFilledTextView() {
    XCTAssertEqual(makeSUT(entry: testEntry1).textView.text, testEntry1.text)
}

func test_saveButton_isEnabledIsControlledByTextView() {
    let sut = makeSUT()

    sut.textView.text = ""
    sut.textViewDidChange(sut.textView)
    XCTAssertFalse(sut.navigationItem.rightBarButtonItem!.isEnabled)

    sut.textView.text = testEntry1.text
    sut.textViewDidChange(sut.textView)
    XCTAssertTrue(sut.navigationItem.rightBarButtonItem!.isEnabled)
}
Enter fullscreen mode Exit fullscreen mode

Lastly, let's test if the callback gets called properly.

func test_saveButton_savesEntryCorrectly() {
    var savedEntry: Entry? = nil
    makeSUT(entry: testEntry1, saveEntryCallback: { savedEntry = $0 }).navigationItem.rightBarButtonItem?.simulateTap()
    XCTAssertEqual(savedEntry, testEntry1)
}
Enter fullscreen mode Exit fullscreen mode

Again, checking the minimum needed supporting code will give you a good feeling.

typealias SaveEntryCallback = (Entry) -> Void

class EntryDetailViewController: UIViewController {

    fileprivate(set) var entry: Entry?
    fileprivate let saveEntryCallback: SaveEntryCallback

    @IBOutlet weak var textView: UITextView!

    init(entry: Entry?,
         saveEntryCallback: @escaping SaveEntryCallback) {

        self.entry = entry
        self.saveEntryCallback = saveEntryCallback
        super.init(nibName: String(describing: EntryDetailViewController.self), bundle: nil)
    }

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

    override func viewDidLoad() {
        super.viewDidLoad()

        setupTextView()
        setupNavigationBar()
    }

    fileprivate lazy var saveButton: UIBarButtonItem = {
        UIBarButtonItem(title: nil, style: .done, target: self, action: #selector(saveButtonTapped))
    }()
}

// MARK: Navigation bar

extension EntryDetailViewController {

    fileprivate func setupNavigationBar() {
        title = (entry?.date ?? Date()).dateString()
        navigationItem.rightBarButtonItem = saveButton
        updateNavigationBar()
    }

    @objc func saveButtonTapped(_ sender: Any) {
        saveEntryCallback(Entry(date: entry?.date ?? Date(), text: textView.text))
        navigationController?.popViewController(animated: true)
    }

    fileprivate func updateNavigationBar() {
        saveButton.title = entry != nil ? "Update" : "Save"
        navigationItem.rightBarButtonItem?.isEnabled = !textView.text.isEmpty
    }
}

// MARK: Text view

extension EntryDetailViewController: UITextViewDelegate {

    fileprivate func setupTextView() {
        textView.text = entry?.text
        textView.becomeFirstResponder()
    }

    func textViewDidChange(_ textView: UITextView) {
        updateNavigationBar()
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Testing the factory

When we test the factory, we're basically only curious if the view controllers are being created the way we expect them to be.

func test_diaryList_rendersControllerWithValidEntries() {
    XCTAssertEqual(makeDiaryListController().entries.count, 0)
    dataSource.save(entry: testEntry1)
    XCTAssertEqual(makeDiaryListController().entries.count, 1)
}

func test_newEntry_rendersWithEmptyEntry() {
    XCTAssertNil(makeNewEntryController().entry)
}

func test_newEntry_rendersWithEntry() {
    XCTAssertEqual(makeEntryDetailController(entry: testEntry1).entry, testEntry1)
}
Enter fullscreen mode Exit fullscreen mode

6. Testing the flows

Testing complete flows is a bit trickier. Actually, in our case this comes from the lazy architecture design for the demo project. Since the factory is the source of truth in the application, these tests are basically end-to-end tests and cover a whole flow.

For example, this one tests if saving a new entry on the new entry screen actually creates a new entry in the data source and navigates back to the list.

func test_savingNewEntry() {
    let controller = makeNewEntryController()
    controller.textView.text = testText1
    controller.textViewDidChange(controller.textView)
    controller.navigationItem.rightBarButtonItem?.simulateTap()

    XCTAssertEqual(dataSource.sortedEntries.count, 1)
    XCTAssertEqual(navigationController.viewControllers.count, 1)
    guard let _ = navigationController.viewControllers[0] as? DiaryListViewController else {
        return XCTFail("Diary list screen hasn't been created after saving an entry")
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarily, this one tests if removing an entry from the list will reset the navigation stack to a new list with the updated data source.

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

    let controller = makeDiaryListController()
    XCTAssertEqual(controller.entryList.numberOfRows(inSection: 0), 3)
    controller.entryList.remove(row: 1)

    // A new list is created and added as the root view controller at this point
    guard let newController = navigationController.viewControllers[0] as? DiaryListViewController else {
        return XCTFail("New list hasn't been created after removing an entry")
    }

    let _ = newController.view
    XCTAssertEqual(newController.entryList.numberOfRows(inSection: 0), 2)
    XCTAssertEqual(dataSource.sortedEntries.count, 2)
}
Enter fullscreen mode Exit fullscreen mode

The last two are quite self-explanatory. They check if the app routes to the proper screen based on the matching user action.

func test_startingAddingNewEntries() {
    let controller = makeDiaryListController()
    XCTAssertEqual(controller.entryList.numberOfRows(inSection: 0), 0)
    controller.navigationItem.rightBarButtonItem?.simulateTap()

    XCTAssertEqual(navigationController.viewControllers.count, 2)
    guard let _ = navigationController.viewControllers[1] as? EntryDetailViewController else {
        return XCTFail("Entry detail screen hasn't been created after tapping on the add button")
    }
}

func test_entrySelection() {
    dataSource.save(entry: testEntry1)
    dataSource.save(entry: testEntry2)

    let controller = makeDiaryListController()
    XCTAssertEqual(controller.entryList.numberOfRows(inSection: 0), 2)
    controller.entryList.select(row: 1)

    XCTAssertEqual(navigationController.viewControllers.count, 2)
    guard let _ = navigationController.viewControllers[1] as? EntryDetailViewController else {
        return XCTFail("Entry detail screen hasn't been created after selecting an entry")
    }
}
Enter fullscreen mode Exit fullscreen mode

App architecture

Alt Text

Let's review quickly our app structure. We implemented the following main modules:

  • App
    The application's main module. Creates a data source, a router, and a factory instance, then displays the diary list with the router.

  • UI
    Plain UIViewControllers, that display the entry list and the entry details and forwards potential callbacks (removing and adding entries).

  • Data
    The entry data model, the interface description and the implementation of the data source.

  • Routing
    The interface of how a view controller factory should behave, the interface and the implementation of our router.

If we take a look back to our concept, that's very similar to what we wanted. Of course, this setup only serves demo purposes, a production-ready app's architecture would look more sophisticated.

Summary

Even though our demo architecture was not optimal, with being test-driven we could reach 100% coverage. In case we'd need to extend our app with new features, it's highly likely we'd catch all potential bugs on time.

Alt Text

All in all, applying TDD is really interesting and fun. It somehow reverses how a developer thinks about implementing an app. My personal main takeaways are that UI comes last and that I should only implement what I actually need. Thanks a lot for reading!

The source code for the article is available on Github.

💖 💪 🙅 🚩
ishouldhaveknown
Tamas Dancsi

Posted on March 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related