Behavior-Driven Testing Tutorial for iOS with Quick & Nimble

In this behavior-driven testing tutorial, you’ll learn how to write tests for iOS apps and games using Quick and Nimble. By Shai Mishali.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Happy Path Isn’t The Only Path

All of the tests you’ve written up until now have one thing in common: they describe the correct behavior of your app while following the happy path. You verified that when the player plays the correct moves, the board behaves correctly. But what about the not-so-happy path?

When writing tests, you mustn’t forget the concept of expected errors. You, as a developer, should have the ability to confirm your board behaves correctly even when your player doesn’t (e.g. makes an illegal move).

Consider the two final user stories for this tutorial:

  • Playing a move that was already played should throw an error.
  • Playing a move when the game is already won should throw an error.

Nimble provides a handy matcher named throwError() you can use to verify this behavior.

Start with verifying that an already played move can’t be played again.

Add the following code right below the last context() you’ve added, while still inside the describe("playing") block:

context("a move that was already played") {
  it("should throw an error") {
    try! board.play(at: 0) // 1

    // 2
    expect { try board.play(at: 0) }
      .to(throwError(Board.PlayError.alreadyPlayed))
  }
}

Here’s what this does:

  1. You play a move at position 0.
  2. You play a move at the same position, and expect it to throw Board.PlayerError.alreadyPlayed. When asserting error throwing, expect takes a closure, in which you can run the code that causes the error to be thrown.

As you have come to expect from Quick tests, the assertion reads much like an English sentence: expect playing the board to throw error – already played.

Run the test suite again by going to Product ▸ Test or using the Command + U shortcut.

The last user story you’re going to cover today will be: Playing a move when the game is already won should throw an error.

This test should feel relatively similar to the previous Arrange, Act and Assert tests: you’ll need to bring the board to a winning state, and then try to play another move while the board has already been won.

Add the following code right below the last context() you added for the previous test:

context("a move while the game was already won") {
  it("should throw an error") {
    // Arrange
    try! board.play(at: 0)
    try! board.play(at: 1)
    try! board.play(at: 3)
    try! board.play(at: 2)
    try! board.play(at: 6)

    // Act & Assert
    expect { try board.play(at: 7) }
      .to(throwError(Board.PlayError.noGame))
  }
}

Building on the knowledge you’ve gained in this tutorial, you should be feeling right at home with this test!

You Arrange the board by playing five moves that cause the board to be in a winning state (e.g. .won(.cross)). You then Act & Assert by trying to play a move while the board was already in a winning state, and expect Board.PlayError.noGame to be thrown.

Run your test suite one more time, and give yourself a pat on the back for all those great tests!

Custom Matchers

While writing your tests in this tutorial, you’ve already used several matchers built into Nimble: equal() (and its == operator overload), and .throwError().

Sometimes, you want to create your very own matchers, either to encapsulate some complex form of matching, or to increase the readability of some of your existing tests.

Consider how you might improve the readability of the “playing a winning move should switch to won state” user story mentioned earlier:

expect(board.state) == .won(.cross)

Reword this statement as an English sentence: expect board to be won by cross. Then the test can look like this:

expect(board).to(beWon(by: .cross))

Matchers in Nimble are nothing more than simple functions that return Predicate<T>, where the generic T is the type you compare against. In your case, T will be of type Board.

In Project navigator, right click the AppTacToeTests folder and select New File. Select Swift File and click Next. Name your file Board+Nimble.swift. Confirm that you correctly set the file as a member of your AppTacToeTests target:

Replace the default import Foundation with the following three imports:

import Quick
import Nimble
@testable import AppTacToe

This simply imports Quick and Nimble, and also imports your main target so you can use Board within your matcher.

As mentioned earlier, a Matcher is a simple function returning a Predicate of type Board.

Add the following base of your matcher below your imports:

func beWon(by: Board.Mark) -> Predicate<Board> {
  return Predicate { expression in
    // Error! ...your custom predicate implementation goes here
  }
}

This code defines the beWon(by:) matcher that returns a Predicate<Board>, so it correctly matches against Board.

Inside of your function, you return a new Predicate instance, passing it a closure with a single argument — expression — which is the value or expression you match against. The closure must return a PredicateResult.

At this point you’ll see a build error, since you haven’t yet returned a result. You’ll fix that next.

To generate a PredicateResult, you must consider the following cases:

How the beWon(by:) matcher works

Add the following code inside of your Predicate‘s closure, replacing the comment, // Error!:

// 1
guard let board = try expression.evaluate() else {
  return PredicateResult(status: .fail,
                         message: .fail("failed evaluating expression"))
}

// 2
guard board.state == .won(by) else {
  return PredicateResult(status: .fail,
                         message: .expectedCustomValueTo("be Won by \(by)", "\(board.state)"))
}

// 3
return PredicateResult(status: .matches,
                       message: .expectedTo("expectation fulfilled"))

This predicate implementation might seem confusing at first, but it is quite simple if you take it step-by-step:

  1. You try to evaluate the expression passed to expect(). In this case, the expression is the board itself. If the evaluation fails, you return a failing PredicateResult with a proper message.
  2. You confirm the board’s state is equal to .won(by), where by is the argument passed to the Matcher function. If the state doesn’t match, you return a failing PredicateResult with an .expectedCustomValueTo message.
  3. Finally, if everything looks good and verified, you return a successful PredicateResult.

That’s it! Open BoardSpec.swift and replace the following line:

expect(board.state) == .won(.cross)

With your new matcher:

expect(board).to(beWon(by: .cross))

Run your tests one final time by navigating to Product ▸ Test or using the Command-U shortcut. You should see all of your tests still pass, but this time with your brand new custom Matcher!