
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.
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
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.
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.