Test-driven development in iOS – Part 2

Welcome back! In the previous part we came up with a concept for our demo app, implemented our data source and half of our router using test-driven development. If you haven’t read it yet, make sure to check it out before continuing with the second part.

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

As we said at the end of the previous part, at this point you should get the idea of how the “red-green-refactor” process works in detail. In this part we will discuss the follow-up steps only on a higher-level. 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)
}

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() }
    }
}

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
}

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
}

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")
}

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

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

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

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")
}

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

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

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()
    }
}

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

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")
    }
}

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

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")
    }
}

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.

Leave a Reply

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