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 3 of 4 of this article. Click here to view the first page.

Mocking

In the real code, URLSession was used to start a URLSessionDataTask which returned the response. Since you don’t want to access the internet, you can test that the URLRequest is configured correctly, that the URLSessionDataTask is created and that the URLSessionDataTask is started.

You’re going to create mock versions of the classes involved: MockURLSession and MockURLSessionDataTask, which you can use instead of the real classes.

At the bottom of the WebSourcesTests.swift file, outside the WebSourceTests class, add the following two new classes:

class MockURLSession: URLSession {
  var url: URL?
  var dataTask = MockURLSessionTask()

  override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> MockURLSessionTask {
      self.url = request.url
      return dataTask
  }
}

class MockURLSessionTask: URLSessionDataTask {
  var resumeGotCalled = false

  override func resume() {
    resumeGotCalled = true
  }
}

MockURLSession sub-classes URLSession, supplying an alternative version of dataTask(with:completionHandler:) that stores the URL from the supplied URLRequest and returns a MockURLSessionTask instead of a URLSessionDataTask.

MockURLSessionTask sub-classes URLSessionDataTask and when resume() is called, does not go online but instead sets a flag to show that this has happened.

Add the following to the WebSourceTests class and run the new test:

func testUsingMockURLSession() {
  // 1
  let address = "https://www.random.org/dice/?num=2"
  guard let url = URL(string: address) else {
    XCTFail()
    return
  }
  let request = URLRequest(url: url)

  // 2
  let mockSession = MockURLSession()
  XCTAssertFalse(mockSession.dataTask.resumeGotCalled)
  XCTAssertNil(mockSession.url)

  // 3
  let task = mockSession.dataTask(with: request) { (data, response, error) in }
  task.resume()

  // 4
  XCTAssertTrue(mockSession.dataTask.resumeGotCalled)
  XCTAssertEqual(mockSession.url, url)
}

What’s going on in this test?

  1. Construct the URLRequest as before.
  2. Create a MockURLSession and confirm the initial properties.
  3. Create the MockURLSessionTask and call resume().
  4. Test that the properties have changed as expected.

This test checks the first part of the process: the URLRequest, the URLSession and the URLSessionDataTask, and it tests that the data task is started. What is missing is any test for parsing the returned data.

There are two test cases you need to cover here: if the data returns matches the expected format, and if it does not.

Add these two tests to WebSourcesTests.swift and run them:

func testParsingGoodData() {
  let webSource = WebSource()
  let goodDataString = "<p>You rolled 2 dice:</p>\n<p>\n<img src=\"dice6.png\" alt=\"6\" />\n<img src=\"dice1.png\" alt=\"1\" />\n</p>"
  guard let goodData = goodDataString.data(using: .utf8) else {
    XCTFail()
    return
  }

  let diceArray = webSource.parseIncomingData(data: goodData)
  XCTAssertEqual(diceArray, [6, 1])
}

func testParsingBadData() {
  let webSource = WebSource()
  let badDataString = "This string is not the expected result"
  guard let badData = badDataString.data(using: .utf8) else {
    XCTFail()
    return
  }

  let diceArray = webSource.parseIncomingData(data: badData)
  XCTAssertEqual(diceArray, [])
}

Here you have used expectations to test the network connection, mocking to simulate the networking to allow tests independent of the network and a third-party web site, and finally supplied data to test the data parsing, again independently.

Performance Testing

Xcode also offers performance testing to check how fast your code executes. In Roll.swift, totalForDice() uses flatMap and reduce to calculate the total for the dice, allowing for the fact that value is an optional. But is this the fastest approach?

To test performance, select the High RollerTests group in the File Navigator and use File\New\File… to create a new macOS\Unit Test Case Class named PerformanceTests.

Delete the contents of the class and — you guessed it — add the following import as you’ve done before:

@testable import High_Roller

Insert this test function:

  func testPerformanceTotalForDice_FlatMap_Reduce() {
    // 1
    var roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 20)
    roll.rollAll()

    // 2
    self.measure {
      // 3
      _ = roll.totalForDice()
    }
  }

The sections of this function are as follows:

  1. Set up a Roll with 20 Dice.
  2. self.measure defines the timing block.
  3. This is the code being measured.

Run the test and you will see a result like this:

PerformanceTest

As well as getting the green checkmark symbol, you will see a speed indicator which in my test shows “Time: 0.000 sec (98% STDEV)”. The standard deviation (STDEV) will indicate if there are any significant changes from the previous results. In this case, there is only one result — zero — so STDEV is meaningless. Also meaningless is a result of 0.000 seconds, so the test needs to be longer. The easiest way to do this is to add a loop that repeats the measure block enough times to get an actual time.

Replace the test with the following:

  func testPerformanceTotalForDice_FlatMap_Reduce() {
    var roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 20)
    roll.rollAll()

    self.measure {
      for _ in 0 ..< 10_000 {
        _ = roll.totalForDice()
      }
    }
  }

Run the test again; the result you get will depend on your processor, but I get about 0.2 seconds. Adjust the loop counter from 10_000 until you get around 0.2.

Here are three other possible ways of adding up the total of the dice. Open Roll.swift in the assistant editor and add them as follows:

  func totalForDice2() -> Int {
    let total = dice
      .filter { $0.value != nil }
      .reduce(0) { $0 + $1.value! }
    return total
  }

  func totalForDice3() -> Int {
    let total = dice
      .reduce(0) { $0 + ($1.value ?? 0) }
    return total
  }

  func totalForDice4() -> Int {
    var total = 0
    for d in dice {
      if let dieValue = d.value {
        total += dieValue
      }
    }
    return total
  }

And here are the matching performance tests which you should add to PerformanceTests.swift:

  func testPerformanceTotalForDice2_Filter_Reduce() {
    var roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 20)
    roll.rollAll()

    self.measure {
      for _ in 0 ..< 10_000 {
        _ = roll.totalForDice2()
      }
    }
  }

  func testPerformanceTotalForDice3_Reduce() {
    var roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 20)
    roll.rollAll()

    self.measure {
      for _ in 0 ..< 10_000 {
        _ = roll.totalForDice3()
      }
    }
  }

  func testPerformanceTotalForDice4_Old_Style() {
    var roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 20)
    roll.rollAll()

    self.measure {
      for _ in 0 ..< 10_000 {
        _ = roll.totalForDice4()
      }
    }
  }

Run these tests and work out which option is the fastest. Did you guess which one would win? I didn't!

tortoise

Code Coverage

The final Xcode test tool to discuss is code coverage, which is the measure of how much of your code is covered during a series of tests. It's turned off by default. To turn it on, select Edit Scheme... in the schemes popup at the top of the window. Select Test in the column on the left and then check Gather coverage data.

Turn-On-Code-Coverage

Close that window and press Command-U to re-run all the tests. Once the tests are complete, go to the Report Navigator and select the latest entry.

You'll see the test report showing a series of green checkmarks, plus some timings for the performance tests. If you don't see this, make sure both All toggles are selected at the top left.

Click on Coverage at the top of this display and mouse over the top of the blue bars to see that your tests cover nearly 80% of your code. Amazing work! :]

Code-Coverage-4

The two model objects (Dice and Roll) are very well covered. If you are only going to add some tests, the model is the best place to start.

There is another good, fast way to improve code coverage: delete code that isn't being used. Look at the coverage for AppDelegate.swift, it's at 50%.

Go to the AppDelegate.swift file. On the gutter on the right-hand side, mouse up and down and you’ll see it shows green for methods called during the tests, and red for methods that are not called.

Uncovered-code

In this case, applicationWillTerminate(_:) is not used at all; it's dramatically decreasing the code coverage for this file. Since the app is not using this function, delete it. Now run all the tests again and AppDelegate.swift has jumped to 100% coverage.

This may seem to be cheating the system, but it is actually good practice to remove any dead code that is cluttering up your app. Xcode tries to be helpful when you make a new file and supplies lots of boilerplate code by default, but if you don't need any of this, delete it.

Note: If you find the code coverage gutter and flashes of red and green distracting, turn them off by selecting Hide Code Coverage from the Editor menu. This doesn't stop Xcode gathering the code coverage data, but stops it being displayed while you edit.

Now for the warning about code coverage: it is a tool, not a goal! Some developers and employers treat it as a goal and insist on a certain percentage. But it is possible to get a good percentage without testing meaningfully. Tests have to be well thought out and not just added for the sake of increasing your code coverage.

Tests may call numerous functions in your code without actually checking the result. While a high code coverage number is probably better than a low one, it doesn't say anything about the quality of the tests.