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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Behavior-Driven Testing Tutorial for iOS with Quick & Nimble
25 mins
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:
- You play a move at position 0.
- 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:
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:
- 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 failingPredicateResult
with a proper message. - You confirm the board’s state is equal to
.won(by)
, whereby
is the argument passed to the Matcher function. If the state doesn’t match, you return a failingPredicateResult
with an.expectedCustomValueTo
message. - 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!