February 8, 2022

How to Verify Objects (and Simplify TDD) using ApprovalTests.Swift

Click to play

I'm Jon Reid, one of the maintainers of ApprovalTests.Swift. Llewellyn Falco, the creator of ApprovalTests, likes to use a tic-tac-toe board to demonstrate how you can use approvals for a much simpler style of test-driven development. Normally in TDD, you have to get your test assertion right first. But ApprovalTests lets us evolve that assertion gradually. Let me show you what this looks like.

If you haven’t already seen the Getting Started video, check that out first.

Start at a Whiteboard

Following Llewellyn’s example, let’s start at a whiteboard. Let’s create a tic-tac-toe board and place some markers. And let’s use zero-based coordinates.

Whiteboard with tic-tac-toe

Write the Test in Natural Language

To write a test for this, let’s start by describing this in natural language. Let’s write our test case, but with comments:

func test_board() throws {
    // Create board
    // Place X at 0,0
    // Place O at 2,0
    // Place X at 0,0
    // Verify board
}

Convert the Test into Code

Now that we have a description of the test that we would like, let’s convert this into code:

func test_board() throws {
    // Create board
    let board = TicTacToeBoard()
    // Place X at 0,0
    board.place("X", at: Position(x: 0, y: 0))
    // Place O at 2,0
    board.place("O", at: Position(x: 2, y: 0))
    // Place X at 0,0
    board.place("X", at: Position(x: 2, y: 2))
    // Verify board
    try Approvals.verify(board)
}

Once we have our code, the comments no longer serve us, so we can get rid of those. And that’s a pretty nice-looking test.

func test_board() throws {
    let board = TicTacToeBoard()
    board.place("X", at: Position(x: 0, y: 0))
    board.place("O", at: Position(x: 2, y: 0))
    board.place("X", at: Position(x: 2, y: 2))
    try Approvals.verify(board)
}

Set Up AppCode for the Approvals

I’m using AppCode because it will let me generate code from the call sites. So it’s also handy to use AppCode as the diff tool for ApprovalTests. To do that, go to the Tools menu, create a command-line launcher, and hit OK. Once that’s in place, ApprovalTests will detect it.

AppCode Create Launcher Script dialog

Create the Code Frame

Let’s now begin defining the TicTacToeBoard. We’ll do it right here in the test code.

Position is going to be a struct. Instead of an initializer, let me just make a couple of properties, and we’ll lean on the implicit initializer.

And finally, a place(_:at:) method—this is going to place a marker at a position.

class TicTacToeBoard {
    func place(_ marker: String, at: Position) {}
}
struct Position: Hashable {
    let x: Int
    let y: Int
}

CustomStringConvertible

To work well with ApprovalTests, it’s often nice to have your system under test conform to CustomStringConvertible. We’ll implement its method using another thing from ApprovalTests, some string utilities. In particular, print a 3x3 grid. Let’s start with a cell printer that just prints a dot.

extension TicTacToeBoard: CustomStringConvertible {
    public var description: String {
        StringUtils.printGrid(width: 3, height: 3, cellPrinter: {_, _ in "."})
    }
}

And let’s see what this looks like. I’m going to run tests.

TicTacToeBoard: ...
...
...

I can see that the grid is being printed as a bunch of dots—that’s good. But I’d like that first line to be not on the same line as the name of the type. So let’s add a newline and try that again.

TicTacToeBoard:
...
...
...

There, I like that. And I’m going to approve this—even though this isnt the final goal, its a good incremental step. Now if I run tests again, they will pass.

Its always good to get back to green so that you can continue to refactor and know whether youve broken anything.

Implement the Cell Printer

Let’s start implementing this cell printer closure. I’m going to turn it into a trailing closure with x,y arguments. Let’s look up a position on a board. If nothing is there, then output the period.

public var description: String {
    "\n" + StringUtils.printGrid(width: 3, height: 3) { x, y in
        board[Position(x: x, y: y)] ?? "."
    }
}

To create the frame for board, let's define it as a dictionary of Position to String, starting as empty.

private var board: [Position: String] = [:]

I’m getting an error here that says that Position doesn’t conform to Hashable, which it needs to in order to be a dictionary key. So let’s make it Hashable and try again. The tests still pass.

struct Position: Hashable {

Place Markers on the Board

Now I can try implementing the place(_:at:) method. This will simply place a marker in the board.

func place(_ marker: String, at: Position) {
    board[at] = marker
}

Run tests again. And there we have our tic-tac-toe board:

TicTacToeBoard: 
X.O
...
..X

This looks good, so I’m going to approve this. Compare this to the whiteboard—that’s what we want! With that approved, tests pass.

Questions?

So that’s a quick demonstration of how you can use ApprovalTests to do test-driven development. Instead of using rigid test assertions, the approval process lets us evolve the solution. This is simpler and faster than figuring out upfront how you want to code your assertions.

#ApprovalTests lets you evolve test assertions gradually.

Click to Tweet

If you have any questions about ApprovalTests.Swift, please ask in the comments below. Or you can reach me on Twitter, I’m @qcoding. It would also help if you add the hashtag #ApprovalTests.

Safety Nets and Guardrails: How to Turn Your Next Defect into a Win

Jon Reid

About the author

Programming was fun when I was a kid. But working in Silicon Valley, I saw poor code lead to fear, with real human costs. Looking for ways to make my life better, I learned about Extreme Programming, including unit testing, test-driven development (TDD), and refactoring. Programming became fun again! I've now been doing TDD in Apple environments for 20 years. I'm committed to software crafting as a discipline, hoping we can all reach greater effectiveness and joy. Now a coach with Industrial Logic!

  • Thanks!
    Really interesting and looks like snapshot tests of a component’s state.

    I have few questions:

    On the one hand it looks really convenient that we’re verifying whole state (board) instead of a small part of it, like we do with assertions. This way we can catch errors that we didn’t expect to have.
    However, if we’re working with a complex state, does it add any distraction when reviewing verification?
    Is it possible to focus on a smaller part instead of a whole, or does it negate the whole idea?

    I’m using randomness in my unit-tests to bring more variety to test setup and to catch unexpected errors. I’m not sure if it works well with approvals, or is there actually an approach to make it work this way?

    And finally – would you suggest to use this instrument along with assertions-based unit tests? And where does one shine better than another?

    • Thanks for your questions, Alex.

      Complex state: The power lies in the strength of your diff tool. The better tools show line-by-line diffs while also highlighting within-the-line differences. Another thing that can help with complex state is verifySequence to visualize a sequence of frames.

      Randomness: Out of the box, it would cause problems with randomly-generated test data. But ApprovalTests also has the concept of a “scrubber.” If you can give it something to match, it will replace it with a scrubbed replacement. For example, it comes with a ScrubDates which replaces arbitrary ISO-8601 dates with <date1>, <date2>. I can imagine creating a scrubber to say, “Convert this particular string I give you into <lastName>,” for example.

      Approvals vs. Assertions: Yes, they live happily side-by-side. Approvals work best when testing objects with complex values (such as long strings), lots of properties, or collections of objects. Approvals also has CombinationApprovals supporting combinations of parameters, generating the output for each combination.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}

    Never miss a good story!

    Want to make sure you get notified when I release my next article or video? Then sign up here to subscribe to my newsletter. Plus, you’ll get access to the test-oriented code snippets I use every day!

    >