Unit Testing on macOS: Part 2/2

In the second part of our Unit testing tutorial for macOS you’ll learn about UI tests and code coverage and you learn how to test asynchronous code. By Sarah Reichelt.

Leave a rating/review
Save for later
Share

In Part 1 of this Unit testing tutorial, you learned how to use Test Driven Development to test new code and how to add unit tests to existing code. In this part, you’ll learn how to test the user interface, how to test networking code and learn about a couple of Xcode tools to help your testing.

If you haven’t completed Part 1, or want a fresh start, download the completed project from Part 1 project here. This project uses Swift 3 and requires, at a minimum, Xcode 8 beta 6. Open it in Xcode and press Command-U to run all the tests to confirm that everything is working as expected.

Testing the Interface

As you saw in part 1, Xcode includes the ability to run UITests. While these can be useful, it is much faster to test views and view controllers programmatically. Instead of having Xcode run the app and send fake clicks to interface objects, get your tests to create a new instance of your view or view controller and work with that directly. You can get and set properties, call methods — including IBAction methods — and test the results much more quickly.

Select the High RollerTests group in the File Navigator and use File\New\File… to create a new macOS\Unit Test Case Class named ViewControllerTests. Delete all the code and add the following import to the top of the file:

@testable import High_Roller

Insert the following code into the ViewControllerTests class:

// 1
var vc: ViewController!

override func setUp() {
  super.setUp()

  // 2
  let storyboard = NSStoryboard(name: "Main", bundle: nil)
  vc = storyboard.instantiateController(withIdentifier: "ViewController") as! ViewController

  // 3
  _ = vc.view
}

So what’s going on here?

  1. The entire class will have access to a ViewController property names vc. It’s OK to make this non-optional because if it crashes, that’s still a useful test result.
  2. This view controller is instantiated from the storyboard setup().
  3. To trigger the view lifecycle, get the view controller’s view property. You don’t need to store it; the act of getting it makes the view controller create it correctly.

Instantiating a view controller this way will only work if the view controller has a storyboard ID. Open Main.storyboard, select ViewController and show the Identity Inspector on the right. Set the Storyboard ID to ViewController.

Add-Storyboard-ID-for-ViewController

The first test will confirm that the ViewController was created properly. Go back to ViewControllerTests.swift and add the following test function:

func testViewControllerIsCreated() {
  XCTAssertNotNil(vc)
}

Run the tests; if this test fails or crashes, go back to Main.storyboard and check that you set the storyboard ID correctly.

Build and run the app to have a look at the interface. All the controls are functional, so click the Roll button to roll the dice. Change the settings and roll again; notice that the number of dice can be set using a test field or a stepper and that you set the number of sides for the dice using a popup.

BuildRun2

Before testing that the controls operate as expected, you first need to confirm the interface elements start off with the expected values.

Add this test to ViewControllerTests:

func testControlsHaveDefaultData() {
  XCTAssertEqual(vc.numberOfDiceTextField.stringValue, String(2))
  XCTAssertEqual(vc.numberOfDiceStepper.integerValue, 2)
  XCTAssertEqual(vc.numberOfSidesPopup.titleOfSelectedItem, String(6))
}

Run the tests to make sure the initial setup is valid.

Once that is confirmed, you can test what happens when you change the parameters through the interface. When you change the number in the text field, the value of the stepper should change to match and vice versa.

If you were using the app and clicked on the up or down arrows to change the stepper, the IBAction method numberOfDiceStepperChanged(_:) would be called automatically. Similarly, if you edited the text field, numberOfDiceTextFieldChanged(_:) would be called. When testing, you have to call the IBAction methods manually.

Insert the following two tests into ViewControllerTests:

func testChangingTextFieldChangesStepper() {
  vc.numberOfDiceTextField.stringValue = String(4)
  vc.numberOfDiceTextFieldChanged(vc.numberOfDiceTextField)

  XCTAssertEqual(vc.numberOfDiceTextField.stringValue, String(4))
  XCTAssertEqual(vc.numberOfDiceStepper.integerValue, 4)
}

func testChangingStepperChangesTextField() {
  vc.numberOfDiceStepper.integerValue = 10
  vc.numberOfDiceStepperChanged(vc.numberOfDiceStepper)

  XCTAssertEqual(vc.numberOfDiceTextField.stringValue, String(10))
  XCTAssertEqual(vc.numberOfDiceStepper.integerValue, 10)
}

Run the tests to see the result. You should also test the view controller’s variables and confirm that they are being changed as expected by events from the interface elements.

The view controller has a Roll object which has its own properties. Add the following test to check that the Roll object exists and has the expected default properties:

func testViewControllerHasRollObject() {
  XCTAssertNotNil(vc.roll)
}

func testRollHasDefaultSettings() {
  XCTAssertEqual(vc.roll.numberOfSides, 6)
  XCTAssertEqual(vc.roll.dice.count, 2)
}

Next, you need to confirm that changing a setting using one of the interface elements actually changes the setting in the Roll object. Add the following tests:

    func testChangingNumberOfDiceInTextFieldChangesRoll() {
      vc.numberOfDiceTextField.stringValue = String(4)
      vc.numberOfDiceTextFieldChanged(vc.numberOfDiceTextField)

      XCTAssertEqual(vc.roll.dice.count, 4)
    }

    func testChangingNumberOfDiceInStepperChangesRoll() {
      vc.numberOfDiceStepper.integerValue = 10
      vc.numberOfDiceStepperChanged(vc.numberOfDiceStepper)

      XCTAssertEqual(vc.roll.dice.count, 10)
    }

    func testChangingNumberOfSidesPopupChangesRoll() {
      vc.numberOfSidesPopup.selectItem(withTitle: "20")
      vc.numberOfSidesPopupChanged(vc.numberOfSidesPopup)

      XCTAssertEqual(vc.roll.numberOfSides, 20)
    }

These three tests operate the text field, the stepper and the popup. After each change, they check that the roll property has changed to match.

Open ViewController.swift in the assistant editor and look at rollButtonClicked(_:). It does three things:

  1. Makes sure that any ongoing edit in the number of dice text field is processed.
  2. Tells the Roll struct to roll all the dice.
  3. Displays the results.

You have already written tests to confirm that rollAll() works as expected, but displayDiceFromRoll(diceRolls:numberOfSides:) needs to be tested as part of the interface tests. The display methods are all in ViewControllerDisplay.swift, which is a separate file containing an extension to ViewController. This is just an organizational split to keep ViewController.swift smaller and to keep the display functions all collected in one place.

Look in ViewControllerDisplay.swift and you’ll see a bunch of private functions and one public function: displayDiceFromRoll(diceRolls:numberOfSides:), This clears the display, fills in the textual information and then populates a stack view with a series of sub-views, one for each die.

As with all testing, it is important to start in the right place. The first test to write is one that checks that the results text view and stack view are empty at the start.

Go to ViewControllerTests.swift and add this test:

func testDisplayIsBlankAtStart() {
  XCTAssertEqual(vc.resultsTextView.string, "")
  XCTAssertEqual(vc.resultsStackView.views.count, 0)
}

Run this test to confirm that the display starts off as expected.

Next, add the test below to check if data appears after the Roll button is clicked:

func testDisplayIsFilledInAfterRoll() {
  vc.rollButtonClicked(vc.rollButton)

  XCTAssertNotEqual(vc.resultsTextView.string, "")
  XCTAssertEqual(vc.resultsStackView.views.count, 2)
}

Since the default setting for the number of dice is 2, it’s safe to check that the stack view has two views. But if you don’t know what the settings might be, you can’t test to see whether the data displayed is correct.

Look back at rollButtonClicked(_:) in ViewController.swift. See how it rolls the dice and then displays the result? What if you called displayDiceFromRoll(diceRolls:numberOfSides:) directly with known data? That would allow exact checking of the display.

Add the following test to ViewControllerTests.swift:

func testTextResultDisplayIsCorrect() {
  let testRolls = [1, 2, 3, 4, 5, 6]
  vc.displayDiceFromRoll(diceRolls: testRolls)

  var expectedText = "Total rolled: 21\n"
  expectedText += "Dice rolled: 1, 2, 3, 4, 5, 6 (6 x 6 sided dice)\n"
  expectedText += "You rolled: 1 x 1s,  1 x 2s,  1 x 3s,  1 x 4s,  1 x 5s,  1 x 6s"

  XCTAssertEqual(vc.resultsTextView.string, expectedText)
}

Run it to confirm that the test result is as expected for a roll with 6 six-sided dice showing one of each possible sides.

The stack view shows the results in a more graphical way using dice emojis if possible. Insert this test into ViewControllerTests.swift:

func testGraphicalResultDisplayIsCorrect() {
  let testRolls = [1, 2, 3, 4, 5, 6]
  vc.displayDiceFromRoll(diceRolls: testRolls)

  let diceEmojis = ["\u{2680}", "\u{2681}", "\u{2682}", "\u{2683}", "\u{2684}", "\u{2685}" ]

  XCTAssertEqual(vc.resultsStackView.views.count, 6)

  for (index, diceView) in vc.resultsStackView.views.enumerated() {
    guard let diceView = diceView as? NSTextField else {
      XCTFail("View \(index) is not NSTextField")
      return
    }
    let diceViewContent = diceView.stringValue
    XCTAssertEqual(diceViewContent, diceEmojis[index], "View \(index) is not showing the correct emoji.")
  }
}

Run the tests again to check that the interface is acting as you expect. These last two tests demonstrate a very useful technique for testing, by supplying known data to a method and checking the result.

If you’re feeling keen, it looks like there is some re-factoring that could be done here! :]