UITableView and UICollectionView are the most common objects we use when we want to present a collection of items or hierarchical data in our app. In this article, we will TDD step by step a simple app that shows a list of countries.
STEP 1:
Let’s start by creating a new project and name it CountriesOfTheWorld. Since we want to start from scratch we have to delete all the files that Xcode has created for us. Let’s delete the files ViewController.swift andCountriesOfTheWorldTests.swift. Also, let’s open the Main.storyboard and delete the ViewController. Run the project. You should be able to see a blank screen. If the app crashes please make sure you have done all the above changes.
STEP 2:
It is time for starting TDD our new ViewController. Let’s name our ViewController as CountriesListViewController. So we have to create the CountryListViewControllerTests TestCase:
Just make sure you added it on the Tests target:
Then we get the following warning:
Unless we are planning to have Objective-C code we don’t need the bridging header, so let’s select “Don’t Create”.
Finally, let’s remove all the methods that the template creates for us. Also, let’s add the @testable import CountriesOfTheWorld. The code should look like:
1 2 3 4 5 6 |
import XCTest @testable import CountriesOfTheWorld class CountriesListViewControllerTests: XCTestCase { } |
STEP 3:
We will need tableView to present our countries, so our first test is:
1 2 3 4 5 6 7 8 |
func test_setsTableView() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let countriesListViewCountroller = storyboard.instantiateViewController(withIdentifier: "CountriesListViewCountroller") as! CountriesListViewCountroller _ = countriesListViewCountroller.view XCTAssertNotNil(countriesListViewCountroller.tableView) } |
The code does not compile, so let’s create the CountriesListViewCountroller :
1 2 3 4 5 |
import UIKit class CountriesListViewCountroller: UIViewController { } |
Let’s open our storyboard file and drag on it a UIViewController. Make sure you set it as the initial ViewController. Also, make sure you set the class to CountriesListViewCountroller and also the storyboardID to CountriesListViewCountroller:
Finally, let’s add a UIITableView and connect the IBOutlet with our CountriesListViewCountroller:
Run the test. The test pass.
STEP 4:
Now that we have created our tableView we need to give it a UITableViewDataSource. UITableViewDataSource will provide to the tableView all the data that the tableView needs to display. We can make our UIViewController be the dataSource for our tableView, but this will increase the size of the viewController. Also this decision makes our viewController to have one more responsibility — to provide the data to the tableView.
In OOP we want to split the responsibilities into different objects. For this reason, we will create a new object to be the dataSource.
Enough of upfront design, let’s continue with a test:
1 2 3 4 5 6 7 8 9 10 |
func test_setsDataSource() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let countriesListViewCountroller = storyboard.instantiateViewController(withIdentifier: "CountriesListViewCountroller") as! CountriesListViewCountroller let countriesDataSource = CountriesDataSource() countriesListViewCountroller.dataSource = countriesDataSource _ = countriesListViewCountroller.view XCTAssertTrue(countriesListViewCountroller.tableView.dataSource === countriesDataSource) } |
Here we create a CountriesDataSource object and we assign it to the datasource property of the viewController. All we want to test is that the viewController assigns this object as dataSource to its tableView.
Let’s make it compile by creating the CountriesDataSource file. Make sure to make the CountriesDataSource to conform to the UITabliewDataSource protocol:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import UIKit class CountriesDataSource: NSObject, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { return UITableViewCell() } } |
Now let’s add the dataSource property in the CountriesListViewCountroller:
1 2 3 4 5 6 |
class CountriesListViewCountroller: UIViewController { @IBOutlet weak var tableView: UITableView! var dataSource: CountriesDataSource! } |
Finally, let’s assign the dataSource to the tableView:
1 2 3 4 5 6 7 8 9 10 11 |
class CountriesListViewCountroller: UIViewController { @IBOutlet weak var tableView: UITableView! var dataSource: CountriesDataSource! override func viewDidLoad() { super.viewDidLoad() tableView.dataSource = dataSource } } |
Run the tests. All the tests pass!
We notice that there is some duplicated code, so we can refactor by extracting this code to a separate method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class CountriesListViewControllerTests: XCTestCase { func test_setsTableView() { let countriesListViewCountroller = createSUT() _ = countriesListViewCountroller.view XCTAssertNotNil(countriesListViewCountroller.tableView) } func test_setsDataSource() { let countriesListViewCountroller = createSUT() let countriesDataSource = CountriesDataSource() countriesListViewCountroller.dataSource = countriesDataSource _ = countriesListViewCountroller.view XCTAssertTrue(countriesListViewCountroller.tableView.dataSource === countriesDataSource) } private func createSUT() -> CountriesListViewCountroller { let storyboard = UIStoryboard(name: "Main", bundle: nil) return storyboard.instantiateViewController(withIdentifier: "CountriesListViewCountroller") as! CountriesListViewCountroller } } |
STEP 5:
Now let’s test our dataSource. Create a new testcase file and name it CountriesDataSourceTests. Our list of countries will be in one section so let’s write our first test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import XCTest @testable import CountriesOfTheWorld class CountriesDataSourceTests: XCTestCase { func test_hasOneSection() { let sut = CountriesDataSource() let tablewView = UITableView() tablewView.dataSource = sut let numberOfSections = tablewView.numberOfSections XCTAssertEqual(1, numberOfSections) } } |
The countriesDataSource is a UITableViewDataSource and we want to test that this dataSource functions as expected. So we create a tableView and we assign the sut as the datasource of the tableView. In that way, we can call the UITableView methods and assert if the results are expected.
Run the tests. The test pass. We haven’t seen the test failing, and the reason is that the default behaviour of the UITableView is to have one section. We can assert that the number of sections is two, see the test fail, and then make it pass by setting to one again. We will keep the test because we want it to serve as an active documentation. If for some reason the number of sections on the future becomes two, we want this test to fail.
STEP 6:
Now let’s test that the number of rows is equal to the number of countries:
1 2 3 4 5 6 7 8 9 10 11 |
func test_numberOfRowsAreTheCountriesCount() { let countries = [Country(name: "Greece"), Country(name: "United Kingdom"), Country(name: "France"), Country(name: "Spain")] let sut = CountriesDataSource(countries: countries) let tablewView = UITableView() tablewView.dataSource = sut let numberOfRows = tablewView.numberOfRows(inSection: 0) XCTAssertEqual(countries.count, numberOfRows) } |
Let’s make it compile. Firstly, let’s add the Country struct:
1 2 3 |
struct Country { let name: String } |
And now let’s inject the countries to the CountriesDataSource. Also let’s make it return as the number of rows the number of the countries:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class CountriesDataSource: NSObject, UITableViewDataSource { private let countries: [Country] init(countries: [Country]) { self.countries = countries super.init() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return countries.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { return UITableViewCell() } } |
And let’s fix the tests that are broken:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
func test_hasOneSection() { let sut = CountriesDataSource(countries: []) let tablewView = UITableView() tablewView.dataSource = sut let numberOfSections = tablewView.numberOfSections XCTAssertEqual(1, numberOfSections) } func test_numberOfRowsAreTheCountriesCount() { let countries = [Country(name: "Greece"), Country(name: "United Kingdom"), Country(name: "France"), Country(name: "Spain")] let sut = CountriesDataSource(countries: countries) let tablewView = UITableView() tablewView.dataSource = sut let numberOfRows = tablewView.numberOfRows(inSection: 0) XCTAssertEqual(countries.count, numberOfRows) } |
STEP 7:
Now that we have test that the tableView has the correct number of rows, we want to test that each row shows the correct data:
1 2 3 4 5 6 7 8 9 10 11 12 |
func test_rowShowsCorrectCountry() { let countries = [Country(name: "Greece"), Country(name: "United Kingdom"), Country(name: "France"), Country(name: "Spain")] let sut = CountriesDataSource(countries: countries) let tablewView = UITableView() tablewView.dataSource = sut tablewView.reloadData() let cell = tablewView.cellForRow(at: IndexPath(row: 0, section: 0)) XCTAssertEqual("Greece", cell?.textLabel?.text) } |
And let’s make it pass:
1 2 3 4 5 |
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell() cell.textLabel?.text = countries[indexPath.row].name return cell } |
STEP 8:
We know that creating a cell every time the cellForRow is being called can harm the performance. Let’s change our last test to make sure we dequeue cells:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func test_rowShowsCorrectCountry() { let countries = [Country(name: "Greece"), Country(name: "United Kingdom"), Country(name: "France"), Country(name: "Spain")] let sut = CountriesDataSource(countries: countries) let storyboard = UIStoryboard(name: "Main", bundle: nil) let vc = storyboard.instantiateViewController(withIdentifier: "CountriesListViewCountroller") as! CountriesListViewCountroller _ = vc.view let tablewView = vc.tableView! tablewView.dataSource = sut tablewView.reloadData() let cell = tablewView.cellForRow(at: IndexPath(row: 0, section: 0)) XCTAssertEqual("Greece", cell?.textLabel?.text) XCTAssertEqual("CountryCell", cell?.reuseIdentifier) } |
And let’s make it pass:
1 2 3 4 5 |
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CountryCell", for: indexPath) cell.textLabel?.text = countries[indexPath.row].name return cell } |
1 2 3 4 5 6 7 8 9 10 11 12 |
class CountriesListViewCountroller: UIViewController { @IBOutlet weak var tableView: UITableView! var dataSource: CountriesDataSource! override func viewDidLoad() { super.viewDidLoad() tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CountryCell") tableView.dataSource = dataSource } } |
Run the tests. All the tests pass.
An alternative way to test the dequeue functionality is with a Spy on the UITableView that will make sure that the dequeue functionality is being called with the correct reuse identifier. I generally prefer to avoid using Spies and Mocks on UIKit objects. If there is another way to test the functionality without the Spies and the Mocks (for UIKit objects) I will choose it.
STEP 9:
Run the app. The app shows an empty ViewController. That is because we have tests the objects as units. We will see in a following article how to test the composition of objects but for now, go to appDelegate and add the following code:
1 2 3 4 5 6 7 8 9 10 11 |
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { guard let countriesListViewCountroller = self.window?.rootViewController as? CountriesListViewCountroller else { return true } let countries = [Country(name: "Greece"), Country(name: "United Kingdom"), Country(name: "France"), Country(name: "Spain")] let countriesDataSource = CountriesDataSource(countries: countries) countriesListViewCountroller.dataSource = countriesDataSource return true } |
Now run the app. Finally we can see our list of countries!