In the previous section, we created a class to act as the data source and delegate for the item list table view. In this section, we will implement its properties and methods. But we first need a test case class for ItemListDataProvider
.
Open Project Navigator and select the ToDoTests group. Add a new Unit Test Case
Class, call it ItemListDataProviderTests
, and choose the Controller
folder as the location to store the file. Add the @testable import ToDo
import statement and remove the two test template methods.
The table view should have two sections: one for unchecked to-do items and the other for checked items. Add the following test to ItemListDataProviderTests
:
func testNumberOfSections_IsTwo() { let sut = ItemListDataProvider() let tableView = UITableView() tableView.dataSource = sut let numberOfSections = tableView.numberOfSections XCTAssertEqual(numberOfSections, 2) }
First, we create an instance of ItemListDataProvider
, set up the table view, and then we check whether the table view has the expected number of sections. This test fails because the default number of sections for a table view is one. Open ItemListDataProvider
and add the following code:
func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 2 }
This is enough to make all the tests pass again.
The number of rows in the first section should be the same as the number of to-do items. But where do we get the to-do items from? ItemListDataProvider
needs a property of the ItemManager
type to ask it for the items to present in the table view. Add the following code to ItemListDataProviderTests
:
func testNumberRowsInFirstSection_IsToDoCount() { let sut = ItemListDataProvider() let tableView = UITableView() tableView.dataSource = sut sut.itemManager?.addItem(ToDoItem(title: "First")) }
At this point, we have to stop writing this test because the static analyzer complains that 'ItemListDataProvider' has no member 'itemManager'
. Open ItemListDataProvider
and add the var itemManager: ItemManager?
property. This makes the test compilable again. Add the following code at the end of testNumberRowsInFirstSection_IsToDoCount()
:
XCTAssertEqual(tableView.numberOfRowsInSection(0), 1) sut.itemManager?.addItem(ToDoItem(title: "Second")) XCTAssertEqual(tableView.numberOfRowsInSection(0), 2)
First, we check whether the number of rows in the first section is equal to one after we have added an item to the item manager. Then, we add another item and check whether the the number of rows is equal to two. Run the test. This test fails because the number of row in the table view is always zero as we have not implemented the corresponding data source method to return the correct values. Open ItemListDataProvider
and replace tableView(_:numberOfRowsInSection:)
with the following code:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return itemManager?.toDoCount ?? 0 }
This implementation returns the number of to-do items from itemManager
if itemManager
is not nil; otherwise, it returns zero. Run the tests. Oh, they still fail because the number of rows in the first section is always zero.
The reason for this is that the property required to hold a reference to the item manager is optional, and we never set a value for this property. Therefore, the value of itemManager
is always nil
, and the number of rows returned from the data source method is always zero.
At this point, it is not clear who is going to set the item manager to itemManager
. We will decide this in a later chapter when we put all the modules together to form a complete app. For the tests, we will set itemManager
in them. Add the following line right below let sut = ItemListDataProvider()
in testNumberRowsInFirstSection_IsToDoCount()
:
sut.itemManager = ItemManager()
Run the tests. Now, the first assertion passes but the second one, asserting that the number of rows is two after we have added another item, fails. The reason for this is that table views seem to cache the values returned from tableView(_:numberOfRowsInSection:)
. This is one of the many performance optimizations that are built into table views. We, as developers, need to tell the table view that the data source has changed by calling reloadData()
. Add the following code right after the line where the second to-do item is added to the item manager:
tableView.reloadData()
Run the tests. All the tests pass. Before we move on, let's check whether there is something to refactor. The implementation code looks nice and clean now. But the tests show some duplication. To refactor, let's first add two properties to ItemListDataProviderTests
:
var sut: ItemListDataProvider! var tableView: UITableView!
Then, add the following setup code to setUp()
:
sut = ItemListDataProvider() sut.itemManager = ItemManager() tableView = UITableView() tableView.dataSource = sut
Finally, remove the following code from the test methods because it is no longer needed:
let sut = ItemListDataProvider() sut.itemManager = ItemManager() let tableView = UITableView() tableView.dataSource = sut
Run the tests again to make sure that everything still works.
If the user checks an item in the first section, it should appear in the second section. Add the following test that makes sure the number of rows in the second section is the same as the number of completed items in the item manager:
func testNumberRowsInSecondSection_IsDoneCount() { sut.itemManager?.addItem(ToDoItem(title: "First")) sut.itemManager?.addItem(ToDoItem(title: "Second")) sut.itemManager?.checkItemAtIndex(0) XCTAssertEqual(tableView.numberOfRowsInSection(1), 1) sut.itemManager?.checkItemAtIndex(0) tableView.reloadData() XCTAssertEqual(tableView.numberOfRowsInSection(1), 2) }
This test is similar to the earlier test. First, we add items to the item manager, and then we check an item and see whether the number of rows in the second section matches our expectations. Run the test. The test fails. But look closely. The first assertion passes. This is because the implementation of tableView(_:numberOfRowsInSection:)
returns the number of to-do items, and when the first assertion is called, this is the same as the expected number of done items. This example shows that it is important to start from a failing test. Otherwise, we cannot be sure if we are testing the real thing. So, remove the second assertion and make the test red by replacing tableView(_:numberOfRowsInSection:)
with this code:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let numberOfRows: Int switch section { case 0: numberOfRows = itemManager?.toDoCount ?? 0 case 1: numberOfRows = 0 default: numberOfRows = 0 } return numberOfRows }
Run the tests. Now, the assertion fails because the number of rows in the second section is always zero. To make the test pass, replace the assignment in the case 1
with this line of code:
numberOfRows = 1
Run the tests again. The tests pass. Now, add again the XCTAssertEqual(tableView.numberOfRowsInSection(1), 2)
assertion at the end of testNumberRowsInSecondSection_IsDoneCount()
. The test fails again. This is a good thing because it means that we are actually testing whether the number of rows represents the number of items in the item manager. Replace the assignment in case 1
one more time with this line of code:
numberOfRows = itemManager?.doneCount ?? 0
Run the tests again. All the tests pass. Let's check whether there is something to refactor. Indeed, there is. The implementation does not look good. There is a question mark at the end of itemManager
, and in the switch
statement, we need to implement the default
case even though we know that there will never be more than two sections.
To improve the code, we start by adding an enum for the sections. Add the following code in ItemListDataProvider.swift
but outside the ItemListDataProvider
class:
enum Section: Int { case ToDo case Done }
Now, replace the implementation of tableView(_:numberOfRowsInSection:)
with the following code:
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard let itemManager = itemManager else { return 0 } guard let itemSection = Section(rawValue: section) else { fatalError() } let numberOfRows: Int switch itemSection { case .ToDo: numberOfRows = itemManager.toDoCount case .Done: numberOfRows = itemManager.doneCount } return numberOfRows }
This looks much better. We check in the beginning whether itemManager
is nil
using guard
and return zero if this is the case. Then, we create itemSection
from the argument section
. The guard statement makes it clear that a value for the section
argument can only be 0 or 1 because the Section
enum only has two cases.
Run the tests to make sure that everything still works.
The to-do items should be presented in the table view using a custom table view cell because the cells provided by UIKit can only show an image and two text strings. But we need to show three text strings because we want to show the title, location, and the due date.
Add the following test to make sure that tableView(_:cellForRowAtIndexPath:)
returns our custom cell:
func testCellForRow_ReturnsItemCell() { sut.itemManager?.addItem(ToDoItem(title: "First")) tableView.reloadData() let cell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 0)) XCTAssertTrue(cell is ItemCell) }
Xcode complains that ItemCell
is an undeclared type. Open Project Navigator, add an iOS | Source | Cocoa Touch Class, call it ItemCell
, and make it a subclass of UITableViewCell
. Store it in the Controller
folder, and ensure that it is added to the ToDo target and not to the ToDoTests target. Remove all the template code, such that the file looks like this:
import UIKit class ItemCell: UITableViewCell { }
Now, the test compiles but still fails. Replace the return statement in tableView(_:cellForRowAtIndexPath:)
with the following line of code:
return ItemCell()
This change is enough to make the tests pass. But it is clearly not enough for the feature that we want to implement. For performance reasons, table view cells need to be dequeued. Before we can write a test that makes sure that the cell is dequeued, we need to introduce a very important concept in unit testing—fake objects.
Ideally, a unit test should test one micro feature and nothing else. But in object-oriented programming (OOP), objects talk to each other, exchange data, and react to the changes of their neighbors. As a result, when writing a test, it is often difficult to isolate one specific module from another. But without isolation, a test does not test just one micro feature but many.
To isolate modules from each other, we can use a concept called fake objects. Fake objects act as placeholders for real objects or modules, but they are controlled by test code. This means a test sets up fake objects, controls their behavior, and tests whether the system under the test reacts as expected.
The most important fake objects are mocks, stubs, and fakes. These are explained as follows:
A
that should call method b()
of class B
when something happens, we would create a mock for B
that sets a Boolean value to true
in case b()
is called. In the test, we use this Boolean value to assert whether b()
has been called.For the next test, we will need a table view mock.
As mentioned in the previous section, table view cells should be dequeued. To make sure that this happens, we need a test. The dequeuing is done by calling the dequeueReusableCellWithIdentifier(_:forIndexPath:)
method on the table view. The table view then checks whether there is a cell that can be reused. If not, it creates a new cell and returns it. We are going to use a table view mock to register when the method is called.
In Swift, classes can be defined within other classes. In the case of mocks, this is useful because this way, the mocks are only visible and accessible at the point at which they are needed.
Add the following code to ItemListDataProviderTests.swift
but outside of the ItemListDataProviderTests
class:
extension ItemListDataProviderTests { class MockTableView : UITableView { var cellGotDequeued = false override func dequeueReusableCellWithIdentifier( identifier: String, forIndexPath indexPath: NSIndexPath) -> UITableViewCell { cellGotDequeued = true return super.dequeueReusableCellWithIdentifier( identifier, forIndexPath: indexPath) } } }
We have used an extension of ItemListDataProviderTests
to define a mock of UITableView
. Our mock uses a Boolean property to register when dequeueReusableCellWithIdentifier(_:forIndexPath:)
is called.
Add the following test to ItemListDataProviderTests
:
func testCellForRow_DequeuesCell() { let mockTableView = MockTableView() mockTableView.dataSource = sut mockTableView.registerClass(ItemCell.self, forCellReuseIdentifier: "ItemCell") sut.itemManager?.addItem(ToDoItem(title: "First")) mockTableView.reloadData() _ = mockTableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 0)) XCTAssertTrue(mockTableView.cellGotDequeued) }
In the test, we first create an instance and set up our table view mock. Then, we add an item to the item manager of sut
. Next, we call cellForRowAtIndexPath(_:)
to trigger the method call that we want to test. Finally, we assert that the table view cell is dequeued.
Run this test. It fails because, right now, the cell is not dequeued. Replace the implementation of tableView(_:cellForRowA
tIndexPath:)
with this code:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: indexPath) return cell }
Run the tests. Now, the last added test succeeds. But testCellForRow_ReturnsItemCell()
fails. The reason for this is that we need to register a cell when we want to make use of automatic dequeuing of cells in UITableView
. There are three ways to register a cell. Firstly, we can do this in code, just like we did in testCellForRow_DequeuesCell()
. Secondly, by registering a nib for the cell. Thirdly, by adding a cell with the used reuse identifier to the storyboard. We will implement the third way because we are already using a storyboard for the app.
Open Main.storyboard
in the editor, and add a table view cell to the table view:
In Identity Inspector, change the class of the cell to ItemCell
:
In the Attribute Inspecto
r, set Identifier to ItemCell
:
Next, we need to set up the test case such that it uses the storyboard to create the table view. First, add the following property to ItemListDataProviderTests
:
var controller: ItemListViewController!
Then, replace setUp()
with the following code:
override func setUp() { super.setUp() sut = ItemListDataProvider() sut.itemManager = ItemManager() let storyboard = UIStoryboard(name: "Main", bundle: nil) controller = storyboard.instantiateViewControllerWithIdentifier("ItemListViewController") as! ItemListViewController _ = controller.view tableView = controller.tableView tableView.dataSource = sut }
Instead of creating a table view using an UITableView
initializer, we instantiate an instance of ItemListViewController
from the storyboard and use its table view. The _ = controller.view
call is needed because, otherwise, the table view is nil
.
Run the tests. All the tests pass and there is nothing to refactor.
After the cell is dequeued, the name, location, and the due date should be set to labels in the cell. A common pattern in the implementation of table view cells in iOS is to implement a configCellWithItem(_:)
method in the cell class. The table view data source then just needs to call this method in tableView(_:cellForRowAtIndexPath:)
. There is one drawback of this pattern: the table view cell, which belongs to the view layer, needs to have information on the structure of the model layer. This is not a big problem because the table view cell is already specific to the data it has to present. Nevertheless, if you prefer, you can use a protocol to decouple the item cell from the item object.
To make sure that configCellWithItem(_:)
is called after the cell is dequeued, we will write a test that uses a table view cell mock. Add the following mock class after the table view mock:
class MockItemCell : ItemCell { var configCellGotCalled = false func configCellWithItem(item: ToDoItem) { configCellGotCalled = true } }
The mock registers when configCellWithItem(_:)
is called by setting configCellGotCalled
to true
. Add the following test to ItemListDataProviderTests
:
func testConfigCell_GetsCalledInCellForRow() { let mockTableView = MockTableView() mockTableView.dataSource = sut mockTableView.registerClass(MockItemCell.self, forCellReuseIdentifier: "ItemCell") let toDoItem = ToDoItem(title: "First", itemDescription: "First description") sut.itemManager?.addItem(toDoItem) mockTableView.reloadData() let cell = mockTableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 0)) as! MockItemCell XCTAssertTrue(cell.configCellGotCalled) }
In this test, we use a mock for the table view and for the table view cell. After setting up the table view, we add an item to the item manager. Then, we get the first cell of the table view. This triggers the call of tableView(_:cellForRowAtIndexPath:)
. Finally, we assert that configCellGotCalled
of our table view cell mock is true.
Run the tests to make sure that this test fails. A failing test means that we need to write implementation code.
Add the following line to tableView(_:cellForRowAtIndexPath:)
before the cell is returned:
cell.configCellWithItem(ToDoItem(title: ""))
The static analyzer will complain that 'UITableViewCell' has no member 'configCellWithItem'
. Obviously, we have forgotten to cast the cell to ItemCell
. Add the cast at the end of the line where the cell is dequeued:
let cell = tableView.dequeueReusableCellWithIdentifier("ItemCell", forIndexPath: indexPath) as! ItemCell
Now, the static analyzer complains that 'ItemCell' has no member 'configCellWithItem'
. Open ItemCell.swift
and add the following empty method definition to ItemCell
:
func configCellWithItem(item: ToDoItem) { }
Xcode complains in MockItemCell
that configCellWithItem(_:)
needs the override
keyword. In Swift, whenever you override a method of the superclass, you need to add this keyword. This is a safety feature. In Objective-C, it could happen that you accidentally overrode a method because you didn't know that the method was defined in the superclass. This is not possible in Swift.
Add the keyword to the method definition, such that it looks like this:
override func configCellWithItem(item: ToDoItem) { configCellGotCalled = true }
Now run the tests. All the tests are green again.
Let's check whether there is something to refactor. The testConfigCell_GetsCalledInCellForRow()
test right now just asserts that the method is called. But we can do better. The method configCellWithItem(_:)
gets called with an item as a parameter. This item should be used to fill the label of the cell. We'll extend the test to also test whether the method is called with the expected item.
Replace the table view cell mock with the following code:
class MockItemCell : ItemCell { var toDoItem: ToDoItem? override func configCellWithItem(item: ToDoItem) { toDoItem = item } }
Then, replace the assertion in testConfigCell_GetsCalledInCellForRow()
with this line of code:
XCTAssertEqual(cell.toDoItem, toDoItem)
The test now fails because we do not use the item from the item manager yet. Replace tableView(_:cellForRowAtIndexPath:)
with the following code:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier("ItemCell", forIndexPath: indexPath) as! ItemCell if let toDoItem = itemManager?.itemAtIndex(indexPath.row) { cell.configCellWithItem(toDoItem) } return cell }
After dequeuing the cell, we get toDoItem
from the item manager and call configCellWithItem(_:)
if it succeeds.
Run the tests. All the tests pass. We are now confident that the cell is called with the right to-do item to configure its labels.
Earlier in this chapter, we tested that the number of rows in the first section correspond to the number of unchecked to-do items and the number of rows in the second section to the number of checked to-do items. Now, we need to test that the configuration of the cell in the second section passes a checked item to the configuration method.
Add the following test to ItemListDataProviderTests
:
func testCellInSectionTwo_GetsConfiguredWithDoneItem() { let mockTableView = MockTableView() mockTableView.dataSource = sut mockTableView.registerClass(MockItemCell.self, forCellReuseIdentifier: "ItemCell") let firstItem = ToDoItem(title: "First", itemDescription: "First description") sut.itemManager?.addItem(firstItem) let secondItem = ToDoItem(title: "Second", itemDescription: "Second description") sut.itemManager?.addItem(secondItem) sut.itemManager?.checkItemAtIndex(1) mockTableView.reloadData() let cell = mockTableView.cellForRowAtIndexPath( NSIndexPath(forRow: 0, inSection: 1)) as! MockItemCell XCTAssertEqual(cell.toDoItem, secondItem) }
The test is similar to the earlier one. The main difference is that we add two to-do items to the item manager, and check the second to populate the second section of the table view.
Run the test. The test crashes because the runtime unexpectedly found nil while unwrapping an Optional value
. This is strange because similar code worked before this. The reason for this crash is that UIKit optimizes the second section because the table view has a frame of CGRect.zero
. As a result, cellForRowAtIndexPath(_:)
returns nil
, and the as!
forced unwrapping lets the runtime crash.
Replace the definition of the table view mock in the test with the following code:
let mockTableView = MockTableView( frame: CGRect(x: 0, y: 0, width: 320, height: 480), style: .Plain)
Run the tests again. It doesn't crash anymore but the test fails. We need to write some implementation code.
In the implementation of tableView(_:numberOfRowsInSection:)
, we introduced an enum for the table view sections, which has improved the code a lot. We will take advantage of the enum in the implementation of tableView(_:cellForRowAtIndexPath:)
. Replace the code of tableView(_:cellForRowAtIndexPath:)
with the following code:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: indexPath) as! ItemCell guard let itemManager = itemManager else { fatalError() } guard let section = Section(rawValue: indexPath.section) else { fatalError() } let item: ToDoItem switch section { case .ToDo: item = itemManager.itemAtIndex(indexPath.row) case .Done: item = itemManager.doneItemAtIndex(indexPath.row) } cell.configCellWithItem(item) return cell }
After dequeuing the cell, we use guard
to make sure that the item manager is present and the index path section has a supported value. Then, we switch on the section and assign a to-do item to a constant that is used to configure the cell. Finally, the cell is returned.
Run the tests. All the tests pass.
Look at the previous tests that we have written. They have duplicated code. Let's clean it up a bit. Add the following code to MockTableView
:
class func mockTableViewWithDataSource( dataSource: UITableViewDataSource) -> MockTableView { let mockTableView = MockTableView( frame: CGRect(x: 0, y: 0, width: 320, height: 480), style: .Plain) mockTableView.dataSource = dataSource mockTableView.registerClass(MockItemCell.self, forCellReuseIdentifier: "ItemCell") return mockTableView }
This class method creates a mock table view, sets the data source, and registers the mock table view cell.
Now, we can replace the initialization and setup of the mock table view in testCellForRow_DequeuesCell()
, testConfigCell_GetsCalledInCellForRow()
, and testCellInSectionTwo_GetsConfiguredWithDoneItem()
with:
let mockTableView = MockTableView.mockTableViewWithDataSource(sut)
Run the tests to make sure that everything still works.
When a table view allows the deletion of cells and a user swipes on a cell to the left, on the right-hand side, a red button will appear with the Delete title. In our application, we want to use this button to check and uncheck items. The button title should show the actions that the button is going to perform. Let's write a test to make sure that this is the case for the first section:
func testDeletionButtonInFirstSection_ShowsTitleCheck() { let deleteButtonTitle = tableView.delegate?.tableView?(tableView, titleForDeleteConfirmationButtonForRowAtIndexPath: NSIndexPath(forRow: 0, inSection: 0)) XCTAssertEqual(deleteButtonTitle, "Check") }
Run the tests. The test fails. In ItemListDataProvider
add the method:
func tableView(tableView: UITableView, titleForDeleteConfirmationButtonForRowAtIndexPath indexPath: NSIndexPath) -> String? { return "Check" }
Now, the tests pass. But if you followed the implementation of ItemListDataProvider
carefully, this could surprise you. Open ItemListDataProvider
and have a look at the declaration of the class. Right now, the class only conforms to the table view data source protocol. But why does the test code then compile, and why does the test pass?
In setUp()
of ItemListDataProviderTests
, we set the data source to the system under test, which is an instance of ItemListDataProvider
. But the delegate of the table view is still set to the dataProvider
object that got initialized when the storyboard scene got loaded. Let's write a test to verify whether this assumption is true. If this is true, the data source and delegate of the table view should be of the ItemListDataProvider
type, but they should be different objects:
func testDataSourceAndDelegate_AreNotTheSameObject() { XCTAssert(tableView.dataSource is ItemListDataProvider) XCTAssert(tableView.delegate is ItemListDataProvider) XCTAssertNotEqual(tableView.dataSource as? ItemListDataProvider, tableView.delegate as? ItemListDataProvider) }
Run the tests. All the tests pass. Now that we know what is going on, we can delete this test again and fix this behavior. The instantiation from the storyboard, and the fact that we declared the dataProvider
property to conform to UITableViewDataSource
and UITableViewDelegete
resulted in a coincidental passing test. We need to enforce the explicit conformance of ItemListDataProvider
to UITableViewDelegate
. This is quite simple to perform. In setUp()
, add the following line right below the setting of the table view data source:
tableView.delegate = sut
The static analyzer complains that ItemListDataProvider
does not conform to UITableViewDelegate
. Add the conformance to it like this:
class ItemListDataProvider: NSObject, UITableViewDataSource, UITableViewDelegate { // … }
Now, everything works as expected. Let's move on with the implementation.
In the second section, the title of the Delete button should be Uncheck. Add the following test to ItemListDataProviderTests
:
func testDeletionButtonInFirstSection_ShowsTitleUncheck() { let deleteButtonTitle = tableView.delegate?.tableView?(tableView, titleForDeleteConfirmationButtonForRowAtIndexPath: NSIndexPath(forRow: 0, inSection: 1)) XCTAssertEqual(deleteButtonTitle, "Uncheck") }
Run the tests. The last test fails because of a missing implementation. Replace tableView(_:titleForDeleteConfirmationButtonForRowAtIndexPath:)
with this:
func tableView(tableView: UITableView, titleForDeleteConfirmationButtonForRowAtIndexPath indexPath: NSIndexPath) -> String? { guard let section = Section(rawValue: indexPath.section) else { fatalError() } let buttonTitle: String switch section { case .ToDo: buttonTitle = "Check" case .Done: buttonTitle = "Uncheck" } return buttonTitle }
Here, we used guard
again as well as the Section
enum to make the code clean and easy to read.
Run the tests. All the tests pass.
The last thing we need to make sure in ItemListDataProvider
is that we can check and uncheck items and they then change sections. Unfortunately, like in the last test, we need to invoke the responsible data source method directly in the test. We would like to have some kind of high-level methods to call to simulate the user tapping the Check and Uncheck button, such as in numberOfRowsInSection(_:)
, but UIKit does not provide these. We will see how to use UI tests to simulate the taps of the user later in the book. Here, we will use the data source method to do this. Add the following test to ItemListDataProviderTests
:
func testCheckingAnItem_ChecksItInTheItemManager() { sut.itemManager?.addItem(ToDoItem(title: "First")) tableView.dataSource?.tableView?(tableView, commitEditingStyle: .Delete, forRowAtIndexPath: NSIndexPath(forRow: 0, inSection: 0)) XCTAssertEqual(sut.itemManager?.toDoCount, 0) XCTAssertEqual(sut.itemManager?.doneCount, 1) XCTAssertEqual(tableView.numberOfRowsInSection(0), 0) XCTAssertEqual(tableView.numberOfRowsInSection(1), 1) }
This test fails because we have not implemented tableView(_:commitEditingStyle:forRowAtIndexPath:)
yet. Add the following code to ItemListDataProvider
:
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { itemManager?.checkItemAtIndex(indexPath.row) tableView.reloadData() }
Run the tests. All the tests pass and there is nothing to refactor.
Next, we need to write a test for the unchecking of a to-do item. Add the following test to ItemListDataProviderTests
:
func testUncheckingAnItem_UnchecksItInTheItemManager() { sut.itemManager?.addItem(ToDoItem(title: "First")) sut.itemManager?.checkItemAtIndex(0) tableView.reloadData() tableView.dataSource?.tableView?(tableView, commitEditingStyle: .Delete, forRowAtIndexPath: NSIndexPath(forRow: 0, inSection: 1)) XCTAssertEqual(sut.itemManager?.toDoCount, 1) XCTAssertEqual(sut.itemManager?.doneCount, 0) XCTAssertEqual(tableView.numberOfRowsInSection(0), 1) XCTAssertEqual(tableView.numberOfRowsInSection(1), 0) }
This test results in a crash because the code in tableView(_:commitEditingStyle:forRowAtIndexPath:)
tries to remove an item for the unchecked items, but the corresponding array in the item manager is already empty. Replace the implementation of tableView(_:commitEditingStyle:forRowAtIndexPath:)
with the following code:
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { guard let itemManager = itemManager else { fatalError() } guard let section = Section(rawValue: indexPath.section) else { fatalError() } switch section { case .ToDo: itemManager.checkItemAtIndex(indexPath.row) case .Done: itemManager.uncheckItemAtIndex(indexPath.row) } tableView.reloadData() }
This implementation code results in a message from the static analyzer that 'ItemManager' has no member 'uncheckItemAtIndex'
. Uh. Looks like we forgot to add it in the previous chapter. Let's add it now. Add the following method to ItemManager
:
func uncheckItemAtIndex(index: Int) { let item = doneItems.removeAtIndex(index) toDoItems.append(item) }
Run the tests. All the tests pass and there is nothing to refactor.
We have tests that make sure that configCellWithItem(_:)
gets called when the cell is prepared. Now, we need tests to make sure that the information is set to the label of ItemCell
. You may ask, "What label?" Yes, you are correct, we also need tests to make sure that ItemCell
has labels in order to present the information.
Select the ToDoTests group in Project Navigator and add a new test case. Call it ItemCellTests
and put it in the Controller
folder. Add the import @testable import ToDo
statement and remove the two template test methods.
To be able to present the data on screen, ItemCell
needs labels. We will add the labels in Interface Builder (IB). This means that to test whether the label is set up when the table view cell is loaded, we need to set up the loading similar to how it will be in the app. The table view needs a data source, but we don't want to set up the real data source because then we will also need an item manager. Instead, we will use a fake object to act as the data source.
Add the following code to ItemCellTests.swift
but outside of the ItemCellTests
class:
extension ItemCellTests { class FakeDataSource: NSObject, UITableViewDataSource { func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1 } func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { return UITableViewCell() } } }
This is the minimal implementation a table view data source needs. Note that we are returning a plain UITableViewCell
. We will see in a minute why this does not matter. Add the following test to ItemCellTests
:
func testSUT_HasNameLabel() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewControllerWithIdentifier( "ItemListViewController") as! ItemListViewController _ = controller.view let tableView = controller.tableView tableView.dataSource = FakeDataSource() let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: NSIndexPath(forRow: 0, inSection: 0)) as! ItemCell XCTAssertNotNil(cell.titleLabel) }
This code creates an instance of the View Controller from the storyboard and sets FakeDataSource
to its table view data source. Then, it dequeues a cell from the table view and asserts that this cell has titleLabel
. This code does not compile because 'ItemCell' has no member 'titleLabel'
. Open ItemCell.swift
in Assistant Editor, and add the property declaration: @IBOutlet weak var titleLabel: UILabel!
.
Run the tests. The last test fails because the property is not connected to the storyboard yet. Open Main.storyboard
and add a label to ItemCell
:
Open ItemCell.swift
in Assistant Editor and hold down the control key while you drag from the label to the property to connect the two.
Run the tests. Now, all the tests pass.
The item cell also needs to show the location if one is set. Add the following test to ItemCellTests
:
func testSUT_HasLocationLabel() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewControllerWithIdentifier( "ItemListViewController") as! ItemListViewController _ = controller.view let tableView = controller.tableView tableView.dataSource = FakeDataSource() let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: NSIndexPath(forRow: 0, inSection: 0)) as! ItemCell XCTAssertNotNil(cell.locationLabel) }
To make this test pass, we need to perform the same steps as we did for the Title label. Add the @IBOutlet weak var locationLabel: UILabel!
property to ItemCell
, add UILabel
to the cell in Main.storyboard
, and connect the two by control-dragging from IB to the property.
Run the tests. All the tests pass. There is a lot of duplication in the last two tests. We need to refactor them. First, add the property var tableView: UITableView!
to ItemCellTests
. Then, add the following code to the end of setUp()
:
let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewControllerWithIdentifier( "ItemListViewController") as! ItemListViewController _ = controller.view tableView = controller.tableView tableView.dataSource = FakeDataSource()
Remove the following code from the two test methods:
let storyboard = UIStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateViewControllerWithIdentifier( "ItemListViewController") as! ItemListViewController _ = controller.view let tableView = controller.tableView tableView.dataSource = FakeDataSource()
Run the tests to make sure that everything still works.
We need a third label. The steps are exactly the same as those in the last tests. Make the changes yourself (don't forget the test) and call the label dateLabel
.
Now that we have the labels in the item cell, we need to fill them with information when the cell is configured. Add the following test to ItemCellTests
:
func testConfigWithItem_SetsTitle() { let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: NSIndexPath(forRow: 0, inSection: 0)) as! ItemCell cell.configCellWithItem(ToDoItem(title: "First")) XCTAssertEqual(cell.titleLabel.text, "First") }
First, we dequeue a cell and then we call configCellWithItem(_:)
on it. Run the tests. The last test fails.
To make the test pass, add the following line to configCellWithItem(_:)
:
titleLabel.text = item.title
Now, all tests pass again and there is nothing to refactor.
Instead of writing two more tests for the other labels, we will add the assertions to the existing tests. Rename the test method to testConfigWithItem_SetsLabelTexts()
, and replace it with this code:
func testConfigWithItem_SetsLabelTexts() { let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: NSIndexPath(forRow: 0, inSection: 0)) as! ItemCell cell.configCellWithItem(ToDoItem(title: "First", itemDescription: nil, timestamp: 1456150025, location: Location(name: "Home"))) XCTAssertEqual(cell.titleLabel.text, "First") XCTAssertEqual(cell.locationLabel.text, "Home") XCTAssertEqual(cell.dateLabel.text, "02/22/2016") }
To make this test pass, replace configCellWithItem(_:)
with the following code:
func configCellWithItem(item: ToDoItem) { titleLabel.text = item.title locationLabel.text = item.location?.name if let timestamp = item.timestamp { let date = NSDate(timeIntervalSince1970: timestamp) let dateFormatter = NSDateFormatter() dateFormatter.dateFormat = "MM/dd/yyyy" dateLabel.text = dateFormatter.stringFromDate(date) } }
Run the tests. All the tests pass. But we need to refactor them. It is not a good idea to create a date formatter every time configCellWithItem(_:)
gets called because the date formatter is the same for all the cells. To improve the code, add the following property to ItemCell
:
lazy var dateFormatter: NSDateFormatter = { let dateFormatter = NSDateFormatter() dateFormatter.dateFormat = "MM/dd/yyyy" return dateFormatter }()
The lazy
keyword indicates that this property is set the first time it is accessed. Now, you can delete the local definition of the date formatter:
let dateFormatter = NSDateFormatter() dateFormatter.dateFormat = "MM/dd/yyyy"
Run the tests. Everything still works.
From the screenshots seen in Chapter 2, Planning and Structuring Your Test-Driven iOS App, we know that the title labels of the cells with the checked items were struck through. An item itself doesn't know that it is checked. The state of an item is managed by the item manager. This means that we need a way to put the state of the item into the configCellWithItem(_:)
method.
Add the following test to check whether the title of the label has been struck through and the other labels are empty:
func testTitle_ForCheckedTasks_IsStrokeThrough() { let cell = tableView.dequeueReusableCellWithIdentifier( "ItemCell", forIndexPath: NSIndexPath(forRow: 0, inSection: 0)) as! ItemCell let toDoItem = ToDoItem(title: "First", itemDescription: nil, timestamp: 1456150025, location: Location(name: "Home")) cell.configCellWithItem(toDoItem, checked: true) let attributedString = NSAttributedString(string: "First", attributes: [NSStrikethroughStyleAttributeName: NSUnderlineStyle.StyleSingle.rawValue]) XCTAssertEqual(cell.titleLabel.attributedText, attributedString) XCTAssertNil(cell.locationLabel.text) XCTAssertNil(cell.dateLabel.text) }
This test looks a bit like the previous one. The main difference between them is that we call configCellWithItem(_:checked:)
with an additional argument, and we assert that attributedText
of titleLabel
is set to the expected attributed string.
This test does not compile. Replace the method signature of configCellWithItem
to this:
func configCellWithItem(item: ToDoItem, checked: Bool = false) { // … }
Open ItemListDataProviderTests.swift
, and also change the signature of the overridden method in MockItemCell
. Run the tests. The last added test fails. To make it pass, replace configCellWithItem(_:checked:)
with the following code:
func configCellWithItem(item: ToDoItem, checked: Bool = false) { if checked { let attributedTitle = NSAttributedString(string: item.title, attributes: [NSStrikethroughStyleAttributeName: NSUnderlineStyle.StyleSingle.rawValue]) titleLabel.attributedText = attributedTitle locationLabel.text = nil dateLabel.text = nil } else { titleLabel.text = item.title locationLabel.text = item.location?.name if let timestamp = item.timestamp { let date = NSDate(timeIntervalSince1970: timestamp) dateLabel.text = dateFormatter.stringFromDate(date) } } }
In case checked
is true
, we set the attributed text to the Title label. Otherwise, we use the code that we had earlier. Run the tests. Everything works and there is nothing to refactor.
For now, we are finished with the to-do item list. In Chapter 6, Putting It All Together, we will connect the list View Controller and data source with the rest of the application.
In the remaining sections of this chapter, we will implement the other two view controllers. But we won't go into as much detail as we have until now because the tests and the implementation are similar to the one we have already written.