Functional tests

Until now, we have written unit tests to drive the implementation. Unit tests test a small micro feature (a unit of the project) under controlled circumstances.

On the other side of the spectrum are functional tests, which test the functionalities of the app in terms of how a user would approach them. The user does not care how the app they're using is implemented. The user cares about what they can do with the app. Functional tests help make sure that the app works as expected.

In this section, we will add a functional test using UI tests, which were introduced with Xcode 7. We will take one functionality (adding a to-do item) and write a test from the user's perspective.

Adding a UI test target

First, we need to add a UI test target to our project. In Project Navigator, select the project and click on the button at the bottom of the view showing the target list:

Adding a UI test target

From the template chooser, go to iOS | Test | iOS UI Testing Bundle. Let the name remain as Xcode suggests it, click on Next, and then on Finish.

Recording and testing

Open Project Navigator and scroll down to the ToDoUITests group. In the group, you'll find a file called ToDoUITests.swift. Click on it to open it in the editor. The structure of the file is similar to the other test cases. In fact, the UI test class is a subclass of XCTextCase, like all our other test cases. Have a look at setUp(). You'll see this line:

XCUIApplication().launch()

This line launches the app for the UI test. Here, you can already see the difference between unit tests and UI tests. A unit test just loads the classes it needs for the test. It doesn't matter how the classes are put together or how the user interacts with the app. In UI tests, the test runner needs to launch the app in order to be able to interact with the real UI. The user interacts with the same UI when they start the app.

Before we write the functional test, open Main.storyboard and add Auto Layout constraints to position the views. Then, add placeholders to the text fields of the input View Controller. The scene in the storyboard should then look something like this:

Recording and testing

Now, go back to ToDoUITests, remove the comment, and position the cursor within the method. At the bottom of the editor, you'll see a red dot:

Recording and testing

Click on it to start recording the UI test. Xcode compiles the app and launches it in the simulator. When the app is running, click on the Add button to navigate to the input screen. Then, put in values for all the fields and click on Save. Remember to put in the date in the 02/22/2016 format because this is the format we used when we built InputViewController.

While you where interacting with the UI, Xcode recorded your actions. Open ToDoUITests and have a look at the code. The recording doesn't always produce the same code but, in general, it should look like this:

let app = XCUIApplication()
app.navigationBars["ToDo.ItemListView"].buttons["Add"].tap()

let titleTextField = app.textFields["Title"]
titleTextField.tap()
titleTextField.typeText("Meeting")

let dateTextField = app.textFields["Date"]
dateTextField.tap()
dateTextField.typeText("02/22/2016")

let locationNameTextField = app.textFields["Location Name"]
locationNameTextField.tap()
locationNameTextField.typeText("Office")

let addressTextField = app.textFields["Address"]
addressTextField.tap()
addressTextField.typeText("Infinite Loop 1, Cupertino")

let descriptionTextField = app.textFields["Description"]
descriptionTextField.tap()
descriptionTextField.typeText("Bring iPad")
app.buttons["Save"].tap()

Let's take a look at what happens when we run the test. Click on the diamond next to the beginning of the test method and switch to the simulator. Like magic, Xcode will run your app and interact with the UI.

But there is something strange. After the test runner has tapped Save, the input screen is dismissed and the list view is shown. But where is the item? It is not added to the list. It looks like we have a bug in our code.

Let's add assertions to the test to make sure we fix this bug. Add the following code at the end of the test:

XCTAssertTrue(app.tables.staticTexts["Meeting"].exists)
XCTAssertTrue(app.tables.staticTexts["02/22/2016"].exists)
XCTAssertTrue(app.tables.staticTexts["Office"].exists)

Now, open InputViewController and let's see if we can spot the problem. If you would like to find the bug yourself, add breakpoints and step through the code (and stop reading further until you have found it).

Did you find it? As described earlier, the geocoder is asynchronous. This means that the call back closure is executed on a different thread. The main thread does not wait until the geocoder has finished its work and dismisses the View Controller before an item can be added to the item manager.

Let's fix this bug. First, remove the following line of code:

dismissViewControllerAnimated(true, completion: nil)

Next, change the code according to the highlighted lines in the following code:

// ...

if let locationName = locationTextField.text
    where locationName.characters.count > 0 {
        if let address = addressTextField.text
            where 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))
                    
                    dispatch_async(dispatch_get_main_queue(), { 
                        () -> Void in
                        self.itemManager?.addItem(item)
                        self.dismissViewControllerAnimated(true, 
                            completion: nil)
                    })
                }
        } else {
            let item = ToDoItem(title: titleString,
                itemDescription: descriptionString,
                timestamp: date?.timeIntervalSince1970,
                location: Location(name: locationName))
            
            self.itemManager?.addItem(item)
            dismissViewControllerAnimated(true, completion: nil)
        }
} else {
    let item = ToDoItem(title: titleString,
        itemDescription: descriptionString,
        timestamp: date?.timeIntervalSince1970,
        location: nil)
    
    self.itemManager?.addItem(item)
    dismissViewControllerAnimated(true, completion: nil)
}

Run the test. Now, the test passes. We have just recorded and written our first functional test. You should add the missing functional tests, for example, in order to check and uncheck items and show their details.

To make sure we haven't broken anything due to these changes, let's run all the tests again. Bummer. The test execution crashes in testSave_UsesGeocoderToGetCoordinateFromAddress() when we try to access the item at index 0. The reason for this crash is that we call addItem(_:) in the save() method on a different thread. This means that the assertions are executed before the item is added to the item manager. We need to make the test asynchronous to account for the change in the implementation.

Open InputViewControllerTests and replace MockInputViewController with this code:

class MockInputViewController : InputViewController {
    
    var dismissGotCalled = false
    var completionHandler: (() -> Void)?
    
    override func dismissViewControllerAnimated(flag: Bool,
        completion: (() -> Void)?) {
            
            dismissGotCalled = true
            completionHandler?()
    }
}

By making this change, we have added the ability to get notified when dismissViewControllerAnimated(_:) is called. We need to change the test to use the input View Controller mock and add code to make the test asynchronous. Replace testSave_UsesGeocoderToGetCoordinateFromAddress() with the following code:

func testSave_UsesGeocoderToGetCoordinateFromAddress() {
    let mockInputViewController = MockInputViewController()
    
    mockInputViewController.titleTextField = UITextField()
    mockInputViewController.dateTextField = UITextField()
    mockInputViewController.locationTextField = UITextField()
    mockInputViewController.addressTextField = UITextField()
    mockInputViewController.descriptionTextField = UITextField()
    
    mockInputViewController.titleTextField.text = "Test Title"
    mockInputViewController.dateTextField.text = "02/22/2016"
    mockInputViewController.locationTextField.text = "Office"
    mockInputViewController.addressTextField.text = "Infinite Loop 1, Cupertino"
    mockInputViewController.descriptionTextField.text = "Test Description"
    
    let mockGeocoder = MockGeocoder()
    mockInputViewController.geocoder = mockGeocoder
    
    mockInputViewController.itemManager = ItemManager()
    
    let expectation = expectationWithDescription("bla")
    
    mockInputViewController.completionHandler = {
        expectation.fulfill()
    }
    
    mockInputViewController.save()
    
    placemark = MockPlacemark()
    let coordinate = CLLocationCoordinate2DMake(37.3316851, -122.0300674)
    placemark.mockCoordinate = coordinate
    mockGeocoder.completionHandler?([placemark], nil)
    
    waitForExpectationsWithTimeout(1, handler: nil)
    
    let item = mockInputViewController.itemManager?.itemAtIndex(0)
    
    let testItem = ToDoItem(title: "Test Title",
        itemDescription: "Test Description",
        timestamp: 1456095600,
        location: Location(name: "Office", coordinate: coordinate))
    
    XCTAssertEqual(item, testItem)
}

This looks more complicated than it is. We have just replaced sut with an instance of MockInputViewController. As seen earlier, because we are not using the storyboard, we need to set the text fields. The highlighted lines of code show the changes needed to make the test asynchronous.

Run all the tests. Now, all the tests pass.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.67.203