A Test-Driven Data Model

In this article by Dr. Dominik Hauser, author of Test-driven Development with Swift, we will cover the following topics:

  • Implementing a To-Do item
  • Implementing the location

iOS apps are often developed using a design pattern called Model-View-Controller (MVC). In this pattern, each class (also, a struct or enum) is either a model object, a view, or a controller. Model objects are responsible to store data. They should be independent from the kind of presentation. For example, it should be possible to use the same model object for an iOS app and command-line tool on Mac.

View objects are the presenters of data. They are responsible for making the objects visible (or in case of a VoiceOver-enabled app, hearable) for users. Views are special for the device that the app is executed on. In the case of a cross-platform application view, objects cannot be shared. Each platform needs its own implementation of the view layer.

Controller objects communicate between the model and view objects. They are responsible for making the model objects presentable.

We will use MVC for our to-do app because it is one of the easiest design patterns, and it is commonly used by Apple in their sample code.

This article starts with the test-driven development of the model layer of our application.

for more info:

(For more resources related to this topic, see here.)

Implementing the To-Do item

A to-do app needs a model class/struct to store information for to-do items.

We start by adding a new test case to the test target. Open the To-Do project and select the ToDoTests group. Navigate to File | New | File, go to iOS | Source | Unit Test Case Class, and click on Next. Put in the name ToDoItemTests, make it a subclass of XCTestCase, select Swift as the language, and click on Next. In the next window, create a new folder, called Model, and click on Create.

Now, delete the ToDoTests.swift template test case.

At the time of writing this article, if you delete ToDoTests.swift before you add the first test case in a test target, you will see a pop up from Xcode, telling you that adding the Swift file will create a mixed Swift and Objective-C target:

Test-driven Development with Swift

This is a bug in Xcode 7.0. It seems that when adding the first Swift file to a target, Xcode assumes that there have to be Objective-C files already. Click on Don't Create if this happens to you because we will not use Objective-C in our tests.

Adding a title property

Open ToDoItemTests.swift, and add the following import expression right below import XCTest:

@testable import ToDo

This is needed to be able to test the ToDo module. The @testable keyword makes internal methods of the ToDo module accessible by the test case.

Remove the two testExample() and testPerformanceExample()template test methods.

The title of a to-do item is required. Let's write a test to ensure that an initializer that takes a title string exists. Add the following test method at the end of the test case (but within the ToDoItemTests class):

func testInit_ShouldTakeTitle() {

   ToDoItem(title: "Test title")

}

The static analyzer built into Xcode will complain about the use of unresolved identifier 'ToDoItem':

Test-driven Development with Swift

We cannot compile this code because Xcode cannot find the ToDoItem identifier. Remember that not compiling a test is equivalent to a failing test, and as soon as we have a failing test, we need to write an implementation code to make the test pass.

To add a file to the implementation code, first click on the ToDo group in Project navigator. Otherwise, the added file will be put into the test group. Go to File | New | File, navigate to the iOS | Source | Swift File template, and click on Next. Create a new folder called Model. In the Save As field, put in the name ToDoItem.swift, make sure that the file is added to the ToDo target and not to the ToDoTests target, and click on Create.

Open ToDoItem.swift in the editor, and add the following code:

struct ToDoItem {

}

This code is a complete implementation of a struct named ToDoItem. So, Xcode should now be able to find the ToDoItem identifier. Run the test by either going to Product | Test or use the U shortcut. The code does not compile because there is Extra argument 'title' in call. This means that at this stage, we could initialize an instance of ToDoItem like this:

let item = ToDoItem()

But we want to have an initializer that takes a title. We need to add a property, named title, of the String type to store the title:

struct ToDoItem {

   let title: String

}

Run the test again. It should pass. We have implemented the first micro feature of our to-do app using TDD. And it wasn't even hard. But first, we need to check whether there is anything to refactor in the existing test and implementation code. The tests and code are clean and simple. There is nothing to refactor as yet.

Always remember to check whether refactoring is needed after you have made the tests green.

But there are a few things to note about the test. First, Xcode shows a warning that Result of initializer is unused. To make this warning go away, assign the result of the initializer to an underscore _ = ToDoItem(title: "Test title"). This tells Xcode that we know what we are doing. We want to call the initializer of ToDoItem, but we do not care about its return value.

Secondly, there is no XCTAssert function call in the test. To add an assert, we could rewrite the test as follows:

func testInit_ShouldTakeTitle() {

   let item = ToDoItem(title: "Test title")

   XCTAssertNotNil(item, "item should not be nil")

}

But in Swift an non-failable initializer cannot return nil. It always returns a valid instance. This means that the XCTAssertNotNil() method is useless. We do not need it to ensure that we have written enough code to implement the tested micro feature. It is not needed to drive the development and it does not make the code better. In the following tests, we will omit the XCTAssert functions when they are not needed in order to make a test fail.

Before we proceed to the next tests, let's set up the editor in a way that makes the TDD workflow easier and faster. Open ToDoItemTests.swift in the editor. Open Project navigator, and hold down the option key while clicking on ToDoItem.swift in the navigator to open it in the assistant editor. Depending on the size of your screen and your preferences, you might prefer to hide the navigator again. With this setup, you have the tests and code side by side, and switching from a test to code and vice versa takes no time. In addition to this, as the relevant test is visible while you write the code, it can guide the implementation.

Adding an item description property

A to-do item can have a description. We would like to have an initializer that also takes a description string. To drive the implementation, we need a failing test for the existence of that initializer:

func testInit_ShouldTakeTitleAndDescription() {

   _ = ToDoItem(title: "Test title",

   itemDescription: "Test description")

}

Again, this code does not compile because there is Extra argument 'itemDescription' in call. To make this test pass, we add a itemDescription of type String? property to ToDoItem:

struct ToDoItem {

   let title: String

   let itemDescription: String?

}

Run the tests. The testInit_ShouldTakeTitleAndDescription()test fails (that is, it does not compile) because there is Missing argument for parameter 'itemDescription' in call. The reason for this is that we are using a feature of Swift where structs have an automatic initializer with arguments setting their properties. The initializer in the first test only has one argument, and, therefore, the test fails. To make the two tests pass again, replace the initializer in testInit_ShouldTakeTitleAndDescription() with this:

toDoItem(title: "Test title", itemDescription: nil)

Run the tests to check whether all the tests pass again. But now the initializer in the first test looks bad. We would like to be able to have a short initializer with only one argument in case the to-do item only has a title. So, the code needs refactoring. To have more control over the initialization, we have to implement it ourselves. Add the following code to ToDoItem:

init(title: String, itemDescription: String? = nil) {

   self.title = title

   self.itemDescription = itemDescription

}

This initializer has two arguments. The second argument has a default value, so we do not need to provide both arguments. When the second argument is omitted, the default value is used.

Before we refactor the tests, run the tests to make sure that they still pass. Then, remove the second argument from the initializer in testInit_ShouldTakeTitle():

func testInit_ShouldTakeTitle() {

   _ = ToDoItem(title: "Test title")

}

Run the tests again to make sure that everything still works.

Removing a hidden source for bugs

To be able to use a short initializer, we need to define it ourselves. But this also introduces a new source for potential bugs. We can remove the two micro features we have implemented and still have both tests pass. To see how this works, open ToDoItem.swift, and comment out the properties and assignment in the initializer:

struct ToDoItem {

   //let title: String

   //let itemDescription: String?

  

   init(title: String, itemDescription: String? = nil) {

      

       //self.title = title

       //self.itemDescription = itemDescription

   }

}

Run the tests. Both tests still pass. The reason for this is that they do not check whether the values of the initializer arguments are actually set to any the ToDoItem properties. We can easily extend the tests to make sure that the values are set. First, let's change the name of the first test to testInit_ShouldSetTitle(), and replace its contents with the following code:

let item = ToDoItem(title: "Test title")

XCTAssertEqual(item.title, "Test title",

   "Initializer should set the item title")

This test does not compile because ToDoItem does not have a property title (it is commented out). This shows us that the test is now testing our intention. Remove the comment signs for the title property and assignment of the title in the initializer, and run the tests again. All the tests pass. Now, replace the second test with the following code:

func testInit_ShouldSetTitleAndDescription() {

   let item = ToDoItem(title: "Test title",

       itemDescription: "Test description")

 

   XCTAssertEqual(item.itemDescription , "Test description",

       "Initializer should set the item description")

}

Remove the remaining comment signs in ToDoItem, and run the tests again. Both tests pass again, and they now test whether the initializer works.

Adding a timestamp property

A to-do item can also have a due date, which is represented by a timestamp. Add the following test to make sure that we can initialize a to-do item with a title, a description, and a timestamp:

func testInit_ShouldSetTitleAndDescriptionAndTimestamp() {

   let item = ToDoItem(title: "Test title",

       itemDescription: "Test description",

       timestamp: 0.0)

 

   XCTAssertEqual(0.0, item.timestamp,

       "Initializer should set the timestamp")

}

Again, this test does not compile because there is an extra argument in the initializer. From the implementation of the other properties, we know that we have to add a timestamp property in ToDoItem and set it in the initializer:

struct ToDoItem {

   let title: String

   let itemDescription: String?

   let timestamp: Double?

  

   init(title: String,

       itemDescription: String? = nil,

       timestamp: Double? = nil) {

      

           self.title = title

           self.itemDescription = itemDescription

           self.timestamp = timestamp

   }

}

Run the tests. All the tests pass. The tests are green, and there is nothing to refactor.

Adding a location property

The last property that we would like to be able to set in the initializer of ToDoItem is its location. The location has a name and can optionally have a coordinate. We will use a struct to encapsulate this data into its own type. Add the following code to ToDoItemTests:

func testInit_ShouldSetTitleAndDescriptionAndTimestampAndLocation() {

   let location = Location(name: "Test name")

}

The test is not finished, but it already fails because Location is an unresolved identifier. There is no class, struct, or enum named Location yet. Open Project navigator, add Swift File with the name Location.swift, and add it to the Model folder. From our experience with the ToDoItem struct, we already know what is needed to make the test green. Add the following code to Location.swift:

struct Location {

   let name: String

}

This defines a Location struct with a name property and makes the test code compliable again. But the test is not finished yet. Add the following code to testInit_ShouldSetTitleAndDescriptionAndTimestampAndLocation():

func testInit_ShouldTakeTitleAndDescriptionAndTimestampAndLocation() {

   let location = Location(name: "Test name")

   let item = ToDoItem(title: "Test title",

       itemDescription: "Test description",

       timestamp: 0.0,

       location: location)

 

   XCTAssertEqual(location.name, item.location?.name,

       "Initializer should set the location")

}

Unfortunately, we cannot use location itself yet to check for equality, so the following assert does not work:

XCTAssertEqual(location, item.location,

   "Initializer should set the location")

The reason for this is that the first two arguments of XCTAssertEqual() have to conform to the Equatable protocol.

Again, this does not compile because the initializer of ToDoItem does not have an argument called location. Add the location property and the initializer argument to ToDoItem. The result should look like this:

struct ToDoItem {

   let title: String

   let itemDescription: String?

   let timestamp: Double?

   let location: Location?

  

   init(title: String,

       itemDescription: String? = nil,

       timestamp: Double? = nil,

       location: Location? = nil) {

      

           self.title = title

           self.itemDescription = itemDescription

           self.timestamp = timestamp

           self.location = location

   }

}

Run the tests again. All the tests pass and there is nothing to refactor.

We have now implemented a struct to hold the to-do items using TDD.

Implementing the location

In the previous section, we added a struct to hold the location information. We will now add tests to make sure Location has the needed properties and initializer.

The tests could be added to ToDoItemTests, but they are easier to maintain when the test classes mirror the implementation classes/structs. So, we need a new test case class.

Open Project navigator, select the ToDoTests group, and add a unit test case class with the name LocationTests. Make sure to go to iOS | Source | Unit Test Case Class because we want to test the iOS code and Xcode sometimes preselects OS X | Source. Choose to store the file in the Model folder we created previously.

Set up the editor to show LocationTests.swift on the left-hand side and Location.swift in the assistant editor on the right-hand side. In the test class, add @testable import ToDo, and remove the testExample() and testPerformanceExample()template tests.

Adding a coordinate property

To drive the addition of a coordinate property, we need a failing test. Add the following test to LocationTests:

func testInit_ShouldSetNameAndCoordinate() {

   let testCoordinate = CLLocationCoordinate2D(latitude: 1,

       longitude: 2)

   let location = Location(name: "",

       coordinate: testCoordinate)

 

   XCTAssertEqual(location.coordinate?.latitude,

       testCoordinate.latitude,

       "Initializer should set latitude")

   XCTAssertEqual(location.coordinate?.longitude,

       testCoordinate.longitude,

       "Initializer should set longitude")

}

First, we create a coordinate and use it to create an instance of Location. Then, we assert that the latitude and the longitude of the location's coordinate are set to the correct values. We use the 1 and 2 values in the initializer of CLLocationCoordinate2D because it has also an initializer that takes no arguments (CLLocationCoordinate2D()) and sets the longitude and latitude to zero. We need to make sure in the test that the initializer of Location assigns the coordinate argument to its property.

The test does not compile because CLLocationCoordinate2D is an unresolved identifier. We need to import CoreLocation in LocationTests.swift:

import XCTest

@testable import ToDo

import CoreLocation

The test still does not compile because Location does not have a coordinate property yet. Like ToDoItem, we would like to have a short initializer for locations that only have a name argument. Therefore, we need to implement the initializer ourselves and cannot use the one provided by Swift. Replace the contents of Location.swift with the following code:

import CoreLocation

 

struct Location {

   let name: String

   let coordinate: CLLocationCoordinate2D?

  

   init(name: String,

       coordinate: CLLocationCoordinate2D? = nil) {

      

           self.name = ""

           self.coordinate = coordinate

   }

}

Note that we have intentionally set the name in the initializer to an empty string. This is the easiest implementation that makes the tests pass. But it is clearly not what we want. The initializer should set the name of the location to the value in the name argument. So, we need another test to make sure that the name is set correctly.

Add the following test to LocationTests:

func testInit_ShouldSetName() {

   let location = Location(name: "Test name")

   XCTAssertEqual(location.name, "Test name",

       "Initializer should set the name")

}

Run the test to make sure it fails. To make the test pass, change self.name = "" in the initializer of Location to self.name = name. Run the tests again to check that now all the tests pass. There is nothing to refactor in the tests and implementation. Let's move on.

Summary

In this article, we covered the implementation of a to-do item by adding a title property, item description property, timestamp property, and more. We also covered the implementation of a location using the coordinate property.

Resources for Article:


Further resources on this subject:


You've been reading an excerpt of:

Test-Driven iOS Development with Swift

Explore Title