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
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

UI Testing

Time to move on to the UITests. Close the assistant editor and open High_RollerUITests.swift. The default code is very similar to the testing code you’ve been using so far, with just a couple of extra lines in setup().

One interesting thing about UITests is their ability to record interface interactions. Remove the comments from inside testExample(), place the cursor on a blank line inside the function and click the red dot at the bottom left of the edit pane to start recording:

Record-UI-Test

When the app starts, follow this sequence of interactions, pausing after each step to let Xcode write at least one new line:

  1. Click the up arrow on the “Number of dice” stepper.
  2. Click the up arrow on the “Number of dice” stepper again.
  3. Double-click inside the “Number of dice” text field.
  4. Type 6 and press Tab.
  5. Open the “Number of sides” popup and select 12.
  6. Click the “Roll” button.

Click the record button again to stop recording.

Xcode will have filled in the function with details of your actions, but you will see one odd thing where you selected 12 from the popup. Xcode can’t quite decide what option to use, so it shows you a number of possibilities. In a complex interface, this might be important to distinguish between different controls, but in this case the first option is sufficient for your needs.

Record-UI-Test2

Click on the down arrow beside menuItems[“12”] to see the popup. Choosing the one to use is easy enough, but convincing Xcode of your choice is not so straightforward.

Select the first option in the list which will dismiss the popup. Then click on the item which will still have a pale blue background. When it’s selected, the background will have a slightly darker shade of blue; you can then press Return to accept this choice, which will leave your code looking like this:

  func testExample() {
    
    let highRollerWindow = XCUIApplication().windows["High Roller"]
    let incrementArrow = highRollerWindow.steppers.children(matching: .incrementArrow).element
    incrementArrow.click()
    incrementArrow.click()
    
    let textField = highRollerWindow.children(matching: .textField).element
    textField.doubleClick()
    textField.typeText("6\t")
    highRollerWindow.children(matching: .popUpButton).element.click()
    highRollerWindow.menuItems["12"].click()
    highRollerWindow.buttons["Roll"].click()

  }

The main use for recording is to show the syntax for accessing the interface elements. The unexpected thing is that you aren’t getting NSButton or NSTextField references; you’re getting XCUIElements instead. This gives you the ability to send messages and test a limited number of properties. value is an Any that will usually hold the most important content of the XCUIElement.

Using the information in the recording to work out how to access the elements, this test function checks to see that editing the number of dice using the stepper also changes the text field:

func testIncreasingNumberOfDice() {
  let highRollerWindow = XCUIApplication().windows["High Roller"]

  let incrementArrow = highRollerWindow.steppers.children(matching: .incrementArrow).element
  incrementArrow.click()
  incrementArrow.click()

  let textField = highRollerWindow.children(matching: .textField).element
  let textFieldValue = textField.value as? String

  XCTAssertEqual(textFieldValue, "4")
}

Save the file and run the test by clicking in the little diamond in the margin beside it. The app will run, the mouse pointer will be moved to the stepper’s up arrow and it will click twice. This is fun, like having a robot operate your app!

robot

It’s a lot slower than the previous tests, though, so UITesting is not for everyday use.

Network and Asynchronous Tests

So far, everyone is happy. Family games night is going ahead, your role-playing friends have the option to roll all their weird dice, your tests prove that everything is working correctly…. but there is always someone who causes trouble:

“I still don’t trust your app to roll the dice. I found a web page that generates dice rolls using atmospheric noise. I want your app to use that instead.”

Sigh. Head to Random.org to see how this works. If the URL contains a num parameter, the page shows the results of rolling that many 6-sided dice. Inspecting the source code for the page, it looks like this is the relevant section:

<p>You rolled 2 dice:</p>
<p>
<img src="dice6.png" alt="6" />
<img src="dice1.png" alt="1" />
</p>

So you could parse the data returned and use that data for the roll. Check out WebSource.swift and you’ll see this is exactly what it does. But how do you test this?

The first thing is to make a WebSourceTests.swift test file. Select the High RollerTests group in the File Navigator and use File\New\File… to make a new macOS\Unit Test Case Class and name it WebSourceTests.

Delete the contents of the class and add the following import statement:

@testable import High_Roller

Open WebSource.swift in the assistant editor.

Look at findRollOnline(numberOfDice:completion:) in WebSource.swift. This function creates a URLRequest and a URLSession and then combines them into a URLSessionDataTask which tries to download the web page for the selected number of dice.

If data arrives, it parses the result and calls the completion handler with the dice results or an empty array.

As a first attempt at testing, try adding the following to WebSourceTests.swift:

func testDownloadingOnlineRollPage() {
  let webSource = WebSource()
  webSource.findRollOnline(numberOfDice: 2) { (result) in
    XCTAssertEqual(result.count, 2)
  }
}

When you run this test, it passes suspiciously fast. Click in the margin to add a breakpoint to the XCTAssertEqual() line.

breakpoint

Run the test again, and your breakpoint never gets triggered. The test is completing without waiting for the results to come back. This is a bit of a trap, as you could have erroneously assumed that the test passed. Never worry, XCTests has the solution to this: expectations!

Replace the previous test with this one:

func testDownloadingPageUsingExpectation() {
  // 1
  let expect = expectation(description: "waitForWebSource")
  var diceRollsReceived = 0

  let webSource = WebSource()
  webSource.findRollOnline(numberOfDice: 2) { (result) in
    diceRollsReceived = result.count
    // 2
    expect.fulfill()
  }

  // 3
  waitForExpectations(timeout: 10, handler: nil)
  XCTAssertEqual(diceRollsReceived, 2)
}

There are several new things to look at here:

  1. Create an XCTestExpectation with a human-readable description.
  2. When the closure is called after the data has been returned, fulfill this expectation by indicating whatever it’s been waiting for has now happened.
  3. Set up a timeout for the test function to wait until the expectation has been fulfilled. In this case, if the web page hasn’t returned the data within 10 seconds, the expectation will timeout.

This time, put a breakpoint on the XCTAssertEqual() line, and it should trigger and the test will pass for real. If you want to see what happens when an expectation times out, set the timeout to something really small (0.1 works for me) and run the test again.

Now you know how to test asynchronously, which is really useful for network access and long background tasks. But what if you want to test your network code and you don’t have access to the internet, or the site is down, or you just want your tests to run faster?

In this case, you can use a testing technique called mocking to simulate your network call.