Add a test case with the name InputViewControllerTests
, import the ToDo
module, and remove the two template methods. If you have problems with this task, go back to the beginning of the previous sections, where I explained it in more detail.
You have taken a look at the first steps of the TDD of controllers several times now. Therefore, we will perform several steps at once now and put the setup code directly in setUp()
. Firstly, add the property var sut: InputViewController!
. Secondly, add the View Controller class InputViewController
. Again, if you are unsure about how to do this, have a look at the previous sections. Next, add the following setup code to setUp()
:
let storyboard = UIStoryboard(name: "Main", bundle: nil) sut = storyboard .instantiateViewController( withIdentifier: "InputViewController") as! InputViewController _ = sut.view
Add the following test:
func test_HasTitleTextField() { XCTAssertNotNil(sut.titleTextField) }
This test does not compile, because InputViewController
does not have a member called titleTextField
. To make the test compile, add the property @IBOutlet var titleTextField: UITextField!
to InputViewController
. If you run the test, it still does not pass. We already know what is needed to make it pass from the implementation of DetailViewController
. Firstly, add a View Controller to the storyboard. Change its Class and Storyboard ID to InputViewController
. Secondly, add a text field to the storyboard scene, and connect it to the outlet in InputViewController
. This should be enough to make the test pass.
Now add the rest of the text fields and the two buttons (dateTextField
, locationTextField
, addressTextField
, descriptionTextField
, saveButton
, and cancelButton
) in a test-driven way. Make sure that all tests pass before you move on, and don't forget to refactor your code and tests if needed.
In the address field, the user can put in addresses for the to-do items. The app should then fetch the coordinate and store it in the to-do items' location. Apple provides the CLGeocoder
class in CoreLocation
for this task. In the test, we want to mock this class to be independent from the Internet connection. Import the CoreLocation
module (import CoreLocation
), and add the following code to InputViewControllerTests.swift
outside of InputViewControllerTests
:
extension InputViewControllerTests { class MockGeocoder: CLGeocoder { var completionHandler: CLGeocodeCompletionHandler? override func geocodeAddressString( _ addressString: String, completionHandler: @escaping CLGeocodeCompletionHandler) { self.completionHandler = completionHandler } } }
The only thing the mock does is to capture the completion handler when geocodeAddressString(_:completionHandler:)
is called. This way, we can call the completion handler in the test and check whether the system under the test works as expected.
The signature of the completion handler looks like this:
public typealias CLGeocodeCompletionHandler = ([CLPlacemark]?, NSError?) -> Void
The first argument is an optional array of place marks, which are sorted from the best to worst match. In the test, we would like to return a place mark with a defined coordinate to check whether the to-do item is created correctly. The problem is that all the properties in CLPlacemark
are readonly
, and it does not have an initializer that we can use to set the coordinate. Therefore, we need another mock that allows us to override the location property. Add the following class definition to the InputViewControllerTests
extension:
class MockPlacemark : CLPlacemark { var mockCoordinate: CLLocationCoordinate2D? override var location: CLLocation? { guard let coordinate = mockCoordinate else { return CLLocation() } return CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) } }
Now, we are ready for the test. The test is a bit complicated. To clearly show you what is going on, we will show the complete test, and then add implementation code until the test passes. By doing this, we are not going to follow the TDD workflow, because we will get errors from the static analyzer before we have even finished writing the test method. But this way makes it easier to see what is going on. Firstly, add a property for our place mark mock to InputViewControllerTests
:
var placemark: MockPlacemark!
This is needed because the test would crash since the place mark is accessed outside of its definition scope. Add the following test method to InputViewControllerTests
:
func test_Save_UsesGeocoderToGetCoordinateFromAddress() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MM/dd/yyyy" let timestamp = 1456095600.0 let date = Date(timeIntervalSince1970: timestamp) sut.titleTextField.text = "Foo" sut.dateTextField.text = dateFormatter.string(from: date) sut.locationTextField.text = "Bar" sut.addressTextField.text = "Infinite Loop 1, Cupertino" sut.descriptionTextField.text = "Baz" let mockGeocoder = MockGeocoder() sut.geocoder = mockGeocoder sut.itemManager = ItemManager() sut.save() placemark = MockPlacemark() let coordinate = CLLocationCoordinate2DMake(37.3316851, -122.0300674) placemark.mockCoordinate = coordinate mockGeocoder.completionHandler?([placemark], nil) let item = sut.itemManager?.item(at: 0) let testItem = ToDoItem(title: "Foo", itemDescription: "Baz", timestamp: timestamp, location: Location(name: "Bar", coordinate: coordinate)) XCTAssertEqual(item, testItem) }
Let's take a look at what is going on here. Firstly, we create the text for the date text field from an arbitrary timestamp. We set the date text and the other text values to the text fields. Then, we create a geocoder mock and set it to a property of the sut
. This is called a dependency injection. We inject the instance from the test that should be used to fetch the coordinate for the given address. To add an item to the list of to-do items, InputViewController
needs to have an item manager. In the test, we set it to a new instance. Next, we call the method we want to test (save()
). This should call geocodeAddressString(_:completionHandler:)
of our geocoder mock, and as a result, the mock should capture the completion handler from the implementation. In the next step, we call the completion handler with a place mark that has a given coordinate. We expect that the completion handler uses the place mark and information from the text fields to create a to-do item. In the rest of the test methods, we assert that this is actually the case.
Now, let's make the test pass. InputViewController
needs a geocoder. Import CoreLocation
to InputViewController
, and add this property:
lazy var geocoder = CLGeocoder()
Lazy properties are set the first time they are accessed. This way, we can set our mock to geocoder
before we access it in the test for the first time. We inject the dependency in the test. In the implementation code, we can use geocoder
, as it would be a normal property.
Next, we add a property to hold a reference to the item manager:
var itemManager: ItemManager?
To make the test compilable, add the minimal implementation of the save
method:
func save() { }
Now, we need to create a to-do item and add it to the item manager within save()
. Add the following code to save()
:
guard let titleString = titleTextField.text, titleString.characters.count > 0 else { return } let date: Date? if let dateText = self.dateTextField.text, dateText.characters.count > 0 { date = dateFormatter.date(from: dateText) } else { date = nil } let descriptionString = descriptionTextField.text if let locationName = locationTextField.text, locationName.characters.count > 0 { if let address = addressTextField.text, address.characters.count > 0 { geocoder.geocodeAddressString(address) { [unowned self] (placeMarks, error) -> Void in let placeMark = placeMarks?.first let item = ToDoItem( title: titleString, itemDescription: descriptionString, timestamp: date?.timeIntervalSince1970, location: Location( name: locationName, coordinate: placeMark?.location?.coordinate)) self.itemManager?.add(item) } } }
Let's go over the code step by step.
Firstly, we use a guard to get the string from the Title text field. If there is nothing in the field, we immediately return from the method. Next, we get the date and description of the to-do item from the corresponding text fields. The date is created from the string in the text field using a date formatter. Add the date formatter right above save()
:
let dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MM/dd/yyyy" return dateFormatter }()
Then, we check whether a name is given in the Location text field. If this is the case, we check whether an address is given in the Address text field. In this case, we get the coordinate from the geocoder
, create the to-do item, and add it to the item manager.
Run the tests. All the tests pass and there is nothing to refactor.
The implementation of save()
is not finished yet. The minimal input a user has to give is the title. Add tests for the to-do items with less information given by the user.
The last test for this chapter is to test that the Save button is connected to the save()
action. Add the following test to InputViewControllerTests
:
func test_SaveButtonHasSaveAction() { let saveButton: UIButton = sut.saveButton guard let actions = saveButton.actions( forTarget: sut, forControlEvent: .touchUpInside) else { XCTFail(); return } XCTAssertTrue(actions.contains("save")) }
We get the Save button and guard that it has at least one action. If not, we fail the test using XCTFail()
. Then, we assert that the actions
array has a method, the "save"
selector.
Run the tests. The last test fails.
Change the signature of the save
method to @IBAction func save()
, and connect it to the Save button in the storyboard scene (by Ctrl + dragging from the button in the storyboard to the IBAction
in code).
Run the tests again. Now, all the tests pass.