Unit Testing on macOS: Part 1/2

In this Unit testing tutorial for macOS you’ll learn how unit tests can help you to write stable code and give you the confidence that changes don’t break your 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.

Modifying Existing Code

You don't want to ruin your friend’s D&D nights, so head back to DiceTests.swift and add another test:

  func testRollingTwentySidedDice() {
    var testDie = Dice()
    testDie.rollDie(numberOfSides: 20)

    XCTAssertNotNil(testDie.value)
    XCTAssertTrue(testDie.value! >= 1)
    XCTAssertTrue(testDie.value! <= 20)
  }

The compiler complains because rollDie() doesn’t take any parameters. Switch over to the assistant editor and in Dice.swift change the function declaration of rollDie() to expect a numberOfSides parameter:

  mutating func rollDie(numberOfSides: Int) {

But that will make the old test fail because they don’t supply a parameter. You could edit them all, but most dice rolls are for 6-sided dice (no need to tell your role-playing friend that). How about giving the numberOfSides parameter a default value?

Change the rollDie(numberOfSides:) definition to this:

  mutating func rollDie(numberOfSides: Int = 6) {

All the tests now pass, but you are in the same position as before: the tests don’t check that the 20-sided dice roll is really producing values from 1 to 20.

Time to write another test similar to testRollsAreSpreadRoughlyEvenly(), but only for 20-sided dice.

  func testTwentySidedRollsAreSpreadRoughlyEvenly() {
    var testDie = Dice()
    var rolls: [Int: Double] = [:]
    let rollCounter = 2000.0

    for _ in 0 ..< Int(rollCounter) {
      testDie.rollDie(numberOfSides: 20)
      guard let newRoll = testDie.value else {
        XCTFail()
        return
      }

      if let existingCount = rolls[newRoll] {
        rolls[newRoll] = existingCount + 1
      } else {
        rolls[newRoll] = 1
      }
    }

    XCTAssertEqual(rolls.keys.count, 20)

    for (key, roll) in rolls {
      XCTAssertEqualWithAccuracy(roll,
                                 rollCounter / 20,
                                 accuracy: rollCounter / 20 * 0.3,
                                 "Dice gave \(roll) x \(key)")
    }
  }

This test gives seven failures: the number of keys is only 6, and the distribution isn’t even. Have a look in the Issue Navigator for all the details.

IssueNavigator2

You should expect this: rollDie(numberOfSides:) isn’t using the numberOfSides parameter yet.

Replace the 6 in the arc4random_uniform() function call with numberOfSides and Command-U again.

Success! All the tests pass — even the old ones that call the function you just changed.

Refactoring Tests

For the first time, you have some code worth re-factoring. testRollsAreSpreadRoughlyEvenly() and testTwentySidedRollsAreSpreadRoughlyEvenly() use very similar code, so you could separate that out into a private function.

Add the following extension to the end of the DiceTests.swift file, outside the class:

extension DiceTests {

  fileprivate func performMultipleRollTests(numberOfSides: Int = 6) {
    var testDie = Dice()
    var rolls: [Int: Double] = [:]
    let rollCounter = Double(numberOfSides) * 100.0
    let expectedResult = rollCounter / Double(numberOfSides)
    let allowedAccuracy = rollCounter / Double(numberOfSides) * 0.3

    for _ in 0 ..< Int(rollCounter) {
      testDie.rollDie(numberOfSides: numberOfSides)
      guard let newRoll = testDie.value else {
        XCTFail()
        return
      }

      if let existingCount = rolls[newRoll] {
        rolls[newRoll] = existingCount + 1
      } else {
        rolls[newRoll] = 1
      }
    }

    XCTAssertEqual(rolls.keys.count, numberOfSides)

    for (key, roll) in rolls {
      XCTAssertEqualWithAccuracy(roll,
                                 expectedResult,
                                 accuracy: allowedAccuracy,
                                 "Dice gave \(roll) x \(key)")
    }
  }

}

This function name doesn’t start with test, as it’s never run on its own as a test.

Go back to the main DiceTests class and replace testRollsAreSpreadRoughlyEvenly() and testTwentySidedRollsAreSpreadRoughlyEvenly() with the following:

  func testRollsAreSpreadRoughlyEvenly() {
    performMultipleRollTests()
  }

  func testTwentySidedRollsAreSpreadRoughlyEvenly() {
    performMultipleRollTests(numberOfSides: 20)
  }

Run all the tests again to confirm that this works.

Using #line

To demonstrate another useful testing technique, go back to Dice.swift and undo the 20-sided dice change you made to rollDie(numberOfSides:): replace numberOfSides with 6 inside the arc4random_uniform() call. Now run the tests again.

testTwentySidedRollsAreSpreadRoughlyEvenly() has failed, but the failure messages are in performMultipleRollTests(numberOfSides:) — not a terribly useful spot.

Xcode can solve this for you. When defining a helper function, you can supply a parameter with a special default value — #line — that contains the line number of the calling function. This line number can be used in the XCTAssert function to send the error somewhere useful.

In the DiceTests extension, change the function definition of performMultipleRollTests(numberOfSides:) to the following:

fileprivate func performMultipleRollTests(numberOfSides: Int = 6, line: UInt = #line) {

And change the XCTAsserts like this:

XCTAssertEqual(rolls.keys.count, numberOfSides, line: line)

for (key, roll) in rolls {
  XCTAssertEqualWithAccuracy(roll,
                             expectedResult,
                             accuracy: allowedAccuracy,
                             "Dice gave \(roll) x \(key)",
                             line: line)
}

You don’t have to change the code that calls performMultipleRollTests(numberOfSides:line:) because the new parameter is filled in by default. Run the tests again, and you’ll see the error markers are on the line that calls performMultipleRollTests(numberOfSides:line:) — not inside the helper function.

Change rollDie(numberOfSides:) back again by putting numberOfSides in the arc4random_uniform() call, and Command-U to confirm that everything works.

Pat yourself on the back — you’ve learned how to use TDD to develop a fully-tested model class.

victory

Adding Unit Tests to Existing Code

TDD can be great when developing new code, but often you’ll have to retrofit tests into existing code that you didn’t write. The process is much the same, except that you’re writing tests to confirm that existing code works as expected.

To learn how to do this, you’ll add tests for the Roll struct. In this app, the Roll contains an array of Dice and a numberOfSides property. It handles rolling all the dice as well as totaling the result.

Back in the File Navigator, select Roll.swift. Delete all the placeholder code and replace it with the following code:

struct Roll {

  var dice: [Dice] = []
  var numberOfSides = 6

  mutating func changeNumberOfDice(newDiceCount: Int) {
    dice = []
    for _ in 0 ..< newDiceCount {
      dice.append(Dice())
    }
  }

  var allDiceValues: [Int] {
    return dice.flatMap { $0.value}
  }

  mutating func rollAll() {
    for index in 0 ..< dice.count {
      dice[index].rollDie(numberOfSides: numberOfSides)
    }
  }

  mutating func changeValueForDie(at diceIndex: Int, to newValue: Int) {
    if diceIndex < dice.count {
      dice[diceIndex].value = newValue
    }
  }

  func totalForDice() -> Int {
    let total = dice
      .flatMap { $0.value }
      .reduce(0) { $0 - $1 }
    return total
  }

}

(Did you spot the error? Ignore it for now; that’s what the tests are for. :])

Select the High RollerTests group in the File Navigator and use File\New\File... to add a new macOS\Unit Test Case Class called RollTests. Delete all the code inside the test class.

Add the following import to RollTests.swift:

@testable import High_Roller

Open Roll.swift in the assistant editor, and you’ll be ready to write more tests.

First, you want to test that a Roll can be created, and that it can have Dice added to its dice array. Arbitrarily, the test uses five dice.

Add this test to RollTests.swift:

  func testCreatingRollOfDice() {
    var roll = Roll()
    for _ in 0 ..< 5 {
      roll.dice.append(Dice())
    }

    XCTAssertNotNil(roll)
    XCTAssertEqual(roll.dice.count, 5)
  }

Run the tests; so far, so good — the first test passes. Unlike TDD, a failing test is not an essential first step in a retrofit as the code should (theoretically) already work properly.

Next, use the following test to check that the total is zero before the dice are rolled:

  func testTotalForDiceBeforeRolling_ShouldBeZero() {
    var roll = Roll()
    for _ in 0 ..< 5 {
      roll.dice.append(Dice())
    }

    let total = roll.totalForDice()
    XCTAssertEqual(total, 0)
  }

Again this succeeds, but it looks like there is some refactoring to be done. The first section of each test sets up a Roll object and populates it with five dice. If this was moved to setup() it would happen before every test.

Not only that, but Roll has a method of its own for changing the number of Dice in the array, so the tests might as well use and test that.

Replace the contents of the RollTests class with this:

  var roll: Roll!

  override func setUp() {
    super.setUp()

    roll = Roll()
    roll.changeNumberOfDice(newDiceCount: 5)
  }

  func testCreatingRollOfDice() {
    XCTAssertNotNil(roll)
    XCTAssertEqual(roll.dice.count, 5)
  }

  func testTotalForDiceBeforeRolling_ShouldBeZero() {
    let total = roll.totalForDice()
    XCTAssertEqual(total, 0)
  }

As always, run the tests again to check that everything still works.

With five 6-sided dice, the minimum total is 5 and the maximum is 30, so add the following test to check that the total falls between those limits:

  func testTotalForDiceAfterRolling_ShouldBeBetween5And30() {
    roll.rollAll()
    let total = roll.totalForDice()
    XCTAssertGreaterThanOrEqual(total, 5)
    XCTAssertLessThanOrEqual(total, 30)
  }

Run this test — it fails! It looks like the tests have discovered a bug in the code. The problem must be in either rollAll() or totalForDice(), since those are the only two functions called by this test. If rollAll() was failing, the total would be zero. However, returned total is a negative number, so have a look at totalForDice() instead.

There’s the problem: reduce is subtracting instead of adding the values. Change the minus sign to a plus sign:

func totalForDice() -> Int {
  let total = dice
    .flatMap { $0.value }
    // .reduce(0) { $0 - $1 }       // bug line
    .reduce(0) { $0 + $1 }          // fixed
  return total
}

Run your tests again — everything should run perfectly.

Contributors

Over 300 content creators. Join our team.