Go Testing With Functional Fixtures

Writing expressive tests with nothing more than the "testing" package.

September 18, 2020

Foundations

The go test tool, in combination with the standard library "testing" package constitute the foundation of the testing experience in Go. Consider the test function interface:

func Test[Name](T *testing.T) {
	...
}

There are merits to this very simple interface. However I've often lamented the limitations of this approach, wondering why the "testing" package doesn't provide something closer to xUnit. In an object-oriented xUnit, a test case is represented as a method on a class, not a simple standalone function as required by Go. The advantage of the xUnit approach is that common state can be declared as fields on the test case class, which then serve as cohesive and ambient state, available to any test case or extracted helper method without the need to be passed as function arguments. In a traditional go test case, unless one resorts to operating on package level state which is bad and you should feel bad, any state created in the test case function must be passed to extracted helper functions. I find it frustrating to pass the *testing.T everywhere. Table driven testing is a viable solution in situations were inputs and outputs are somewhat simple and test cases don't require varied set up conditions.

I created gunit in order to adapt the go test func to an xUnit test suite. If you can stomach the one call from the test func to gunit.Run(t, ...) than you're off to the races. I've have used my Goland's code generation capabilities ("Live Templates") to make swallowing this pill easier. This approach has served me well and may continue to do so for the forseeable future.

Having said all of that, and in the name of the never ending quest of dependency management I've pondered how I might rid my go.mod of this test-only dependency. I think I've made a breakthrough. Let's walk through my thought process which has evolved over the last several years by presenting a few renditions of the same well-known test case.

In the beginning...

package bowling

import "testing"

func TestSpare(t *testing.T) {
	game := NewGame()
	game.RecordRoll(5)
	game.RecordRoll(5) // spare
	game.RecordRoll(3)
	game.RecordRoll(1)
	for x := 0; x < 16; x++ {
		game.RecordRoll(0)
	}
	score := game.CalculateScore()
	if score != 17 {
		t.Errorf("Expected:", 17, "Actual:", score)
	}
}

Can you see how each statement is quite low-level in nature, requiring cognitive effort to understand? Let's rewrite this test case, attempting to extract functions for higher level concepts:

package bowling

import "testing"

func TestSpare(t *testing.T) {
	game := NewGame()
	rollSpare(game)
	rollSeveral(game, 3, 1)
	rollMany(game, 16, 0)
	assertScore(t, game, 17)
}
func rollSpare(game *Game) {
	rollMany(game, 2, 5)
}
func rollSeveral(game *Game, rolls ...int) {
	for _, roll := range rolls {
		game.RecordRoll(roll)
	}
}
func rollMany(game *Game, times, pins int) {
	for ; times > 0; times-- {
		game.Record.Roll(pins)
	}
}
func assertScore(t *testing.T, game *Game, expected int) {
	t.Helper()
	actual := game.CalculateScore()
	if actual != expected {
		t.Errorf("Expected:", expected, "Actual:", actual)
	}
}

The test definition is definitely more high-level, the extracted functions require the passing of the *Game and the *testing.T, which only becomes more awkward and more complicated tests with additional state.

Enter gunit

Here is yet another implementation of the same test, this time using gunit:

package bowling

import (
	"testing"

	"github.com/smartystreets/gunit"
)

func TestBowlingGameScoringFixture(t *testing.T) {
	gunit.Run(new(BowlingGameScoringFixture), t)
}

type BowlingGameScoringFixture struct {
	*gunit.Fixture
	game *Game
}

func (this *BowlingGameScoringFixture) Setup() {
	this.game = NewGame()
}
func (this *BowlingGameScoringFixture) rollSpare() {
	this.rollMany(2, 5)
}
func (this *BowlingGameScoringFixture) rollMany(times, pins int) {
	for x := 0; x < times; x++ {
		this.game.RecordRoll(pins)
	}
}
func (this *BowlingGameScoringFixture) rollSeveral(roll ...int) {
	for _, roll := range rolls {
		this.game.RecordRoll(roll)
	}
}
func (this *BowlingGameScoringFixture) assertScore(expected int) {
	this.AssertEqual(expected, this.game.Score())
}

func (this *BowlingGameScoringFixture) TestSpare() {
	this.rollSpare()
	this.rollSeveral(3, 1)
	this.rollMany(16, 0)
	this.assertScore(17)
}

This approach feels better to me because I'm free to extract all the helper methods I may wish in ways that promote readability but don't hinder maintainability. Also, the gunit fixture provides basic assertion capabilities such that we no longer have to write in if statement to perform an assertion. But now we have an external dependency, and admittedly, additional overhead. Having written literally hundreds, possibly thousands of these kinds of tests suites to test hundreds of thousands of lines of code I can say that this approach works well, especially in larger, more complicated, test suites where the overhead is amortized. But the fact remains that we have introduced an external dependency.

Now, let's try to illuminate this external dependency.

Above all: Essence

package bowling

import "testing"

func TestBowlingSpare(t *testing.T) {
	_TestBowling(t,
		RollSpare(), Roll(3), Roll(1), RollMany(16, 0), AssertScore(17),
	)
}

This test case, while incredibly concise, is obviously incomplete. But on its own it is quite attractive in that it is almost nothing but the essence of the test case. Here is the code necessary to power this, and subsequent test cases:

func Roll(pins int) BowlingFixtureOption { return RollMany(1, pins) }
func RollSpare() BowlingFixtureOption    { return RollMany(2, 5) }
func RollStrike() BowlingFixtureOption   { return Roll(allPins) }
func RollMany(times int, pins int) BowlingFixtureOption {
	return func(this *BowlingFixture) {
		for ; times > 0; times-- {
			this.game.RecordRoll(pins)
		}
	}
}
func AssertScore(expected int) BowlingFixtureOption {
	return func(this *BowlingFixture) {
		actual := this.game.CalculateScore()
		if actual == expected {
			return
		}
		this.Helper()
		this.Error(expected, actual)
	}
}

func _TestBowling(t *testing.T, options ...BowlingFixtureOption) {
	fixture := &BowlingFixture{T: t, game: new(BowlingGame)}
	for _, option := range options {
		option(fixture)
	}
}

type (
	BowlingFixtureOption func(this *BowlingFixture)

	BowlingFixture struct {
		*testing.T

		game *BowlingGame
	}
)

We still have a Fixture struct, but we aren't depending on any external package. The main difference here is that we are leveraging a technique called "functional options" to manipulate state on the fixture. "Functional options" are described elsewhere by Dave Cheney and Russ Cox:

Testing with "Functional Fixtures"

I've experimented with a few interesting exercises in this style, which I'm calling "Functional Fixtures". Here's a quick demo of the "Bowling Game" kata:

And here's a more extensive demo of the "Environment Controller" kata:

Finally, here's JetBrains "Live Template" you can use to streamline the creation of the initial boilerplate:

func _Test$Name$(t *testing.T, options ...$Name$FixtureOption) {
	t.Helper()
	t.Parallel()
	fixture := &$Name$Fixture{T: t}
	for _, option := range options {
		option(fixture)
	}
}

type (
	$Name$FixtureOption func(this *$Name$Fixture)
	
	$Name$Fixture struct {
		*testing.T
	}
)

Happy testing!

-Michael Whatcott