Testing in Go: Fixtures
Table of Contents
When I was researching the topic of test fixtures, I couldn’t find much about their beginnings. My first search was about the name of the person who coined “test fixtures”. Unfortunately, that was not a fruitful endeavor. The next logical step was to look for the etymology of the phrase “test fixtures”, but the only search result that made sense was a Wikipedia page on the topic.
Judging by the Wiki page, it’s clear that Ruby on Rails has heavily popularized test fixtures as a concept. Likely though, folks that have been in the industry for a longer time will say that the idea of test fixtures is older than Rails itself.
Test fixtures contribute to setting up the system for the testing process by providing it with all the necessary data for initialization. The setup using fixtures is done to satisfy any preconditions there may be for the code under test. For example, code that we want to test might require some configuration before being executed or tested. In such cases, we would have to recreate these preconditions to run the code every time we have to test the code.
More annoyingly, if the configuration of the tested code would change, we would have to update the configuration structure everywhere where we test that particular code.
To avoid such scenarios, we use fixtures. Fixtures allow us to reliably and repeatably create the state our code relies on upon without worrying about the details. When the required state of the code under test changes, we need to tweak a fixture instead of scouring all tests for the code that needs changing.
I know, I know. My introduction made you dizzy from all the praise of fixtures. So let’s stop the sales pitch here and move on to see how simple fixtures can be and how you can master them as another tool in your testing tool belt.
Making a simple grade book #
As always, when talking about code without having code to look at is not great. Let’s introduce an example representing a grade book populated from a CSV file, using a builder function. After, we will create a lookup method per column and add some tests for both functions.
type Record struct {
student string
subject string
grade string
}
type Gradebook []Record
The Record
type will have three attributes: student
, subject
and grade
,
all three of type string
. The Gradebook
type is just a slice of Record
s,
nothing more.
Next, let’s create a builder function for a Gradebook
. We want the function
to be simple - receive a path to a CSV file as an argument and return a
Gradebook
with all of the records parsed from the CSV.
func NewGradebook(csvFile io.Reader) (Gradebook, error) {
var gradebook Gradebook
reader := csv.NewReader(csvFile)
for {
line, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return gradebook, err
}
if len(line) < 3 {
return gradebook, fmt.Errorf("Invalid file structure")
}
gradebook = append(gradebook, Record{
student: line[0],
subject: line[1],
grade: line[2],
})
}
return gradebook, nil
}
Although a bit bloated, the function doesn’t do much. It receives an
io.Reader
as an argument (which is the file reader), wraps it in a CSV
reader, and reads it line by line. For each line it reads, it will create a new
Record
struct and append
it to the collection of Record
’s gradebook
.
After parsing the whole file, it will exit the loop and return the grade book.
Of course, in true Go fashion, we gracefully handle the errors in every step of
the reading and parsing the file. If there’s an error in any scenario, the
function will return the error along with the empty gradebook
.
The last piece of the puzzle is the function that will find all records in the grade book for a particular student:
func (gb *Gradebook) FindByStudent(student string) []Record {
var records []Record
for _, record := range *gb {
if student == record.student {
records = append(records, record)
}
}
return records
}
The FindByStudent
function takes the student
name as argument. First, it
will loop through the Gradebook
’s records and collect the records where the
student name matches. Then, it will return the records found for the particular
student
name.
To manually test the code, let’s create a small CSV file, called grades.csv
:
Jane,Chemistry,A
John,Biology,A
Jane,Algebra,B
Jane,Biology,A
John,Algebra,B
John,Chemistry,C
In the main
function of the file, we will parse it and then get all of Jane’s
grades:
func main() {
csvFile, err := os.Open("grades.csv")
if err != nil {
fmt.Println(fmt.Errorf("error opening file: %v", err))
}
grades, err := NewGradebook(csvFile)
fmt.Printf("%+v\n", grades.FindByStudent("Jane"))
}
The output of the function will be:
$ go run grades.go
[{student:Jane subject:Chemistry grade:A} {student:Jane subject:Algebra grade:B} {student:Jane subject:Biology grade:A}]
From the output, it is clear what are Jane’s grades in the grade book we have created. Having these two types and two functions is good enough to explain how we can use fixtures in the testing we’re about to do.
Testing the builder function #
Whenever we need to test a piece of code, we have to identify its key
components. In other words, we have to understand the essential steps that that
code takes to accomplish its mission. For example, to test the NewGradebook
function, an overly simplified breakdown of its doings would look like:
- Read through each of the lines of the CSV
- When reading through each line, create a new struct from the data
- Put the new struct in the collection of structs
- Return the collection of structs
Now, there’s no need to test if opening a file and parsing it works - we trust
Go to take care of that. So instead, we are interested in two things: will our
function handle invalid CSV files gracefully, and will it create a Gradebook
that we expect from a valid file?
To test the error handling, we will introduce a test function:
func TestNewGradebook_ErrorHandling(t *testing.T) {
cases := []struct {
fixture string
returnErr bool
name string
}{
{
fixture: "testdata/grades/empty.csv",
returnErr: false,
name: "EmptyFile",
},
{
fixture: "testdata/grades/invalid.csv",
returnErr: true,
name: "InvalidFile",
},
{
fixture: "testdata/grades/valid.csv",
returnErr: false,
name: "ValidFile",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewGradebook(tc.fixture)
returnedErr := err != nil
if returnedErr != tc.returnErr {
t.Fatalf("Expected returnErr: %v, got: %v", tc.returnErr, returnedErr)
}
})
}
}
To run these test cases, we will need three accompanying CSV files in the root
of our project: empty.csv
, invalid.csv
and valid.csv
. An empty CSV, an
invalid CSV and a valid CSV file, respectively.
Each of these files is fixtures - files that go together with the test suite,
enabling us to assume the state of the system that we run our tests on. Now,
the content of these files should be evident from the file names. The
invalid.csv
will contain just text, but not in a CSV format. The empty.csv
will be just an empty file, while the valid.csv
file will be a real CSV that
our function can parse and use. Lastly, the nonexisting.csv
actually will not
be a file β we want our tests to fail when this path is passed to the
NewGradebook
function. And this is the first thing we need to remember about
fixtures: we can (and should) create as many fixture files as it makes sense,
but not more.
Fixtures should always be placed in a directory (in our example testdata
) at
the root of our project. We should always put our fixtures in the testdata
directory at the root of our project because go test
will ignore that path
when building our packages. Quoting the output of go help test
:
The go tool will ignore a directory named “testdata”, making it available to hold ancillary data needed by the tests.
Placing it in the root of the package works great because when we run go test
, for each package in the directory tree, go test
will execute the test
binary with its working directory set to the source directory of the package
under test. (Read more about it in Dave Cheney’s
article on the topic.)
In the example above, we used two nested directories: testdata
and grades
.
We use two nested directories because we want to logically group our fixtures
and leave the room for another kind of fixtures within the same project if need
be. Software is built to grow, so why not set some sane defaults from the
start.
Testing the FindByStudent
function #
The functionality of the FindByStudent
function is a linear search through a
Gradebook
type (which is a slice of Record
’s). It compares the student name
from the argument and the name of each of the records in the Gradebook
. When
a match is found, the matching record is added to the collection records
.
Testing this function is can be based on a couple of state assumptions. The
first one is to test FindByStudent
we have to have a Gradebook
available.
The Gradebook
can be in three states: empty, without a matching Record
, and
with a Record
that matches the student name from the argument. If we flipped
this on its head, it would mean that to test the function, we will need three
different Gradebooks
: one empty, one without a matching Record
, and one
with a matching Record
.
To create such Gradebook
’s, we can take two different approaches: define the
Gradebook
’s directly in the test or use a fixture file. Using the first
approach might be preferable, but we will use the second approach to see how we
can use fixtures. While we already have the fixture files from the previous
test, we can use them in the test of the FindByStudent
function:
func TestFindByStudent(t *testing.T) {
cases := []struct {
fixture string
student string
want Gradebook
name string
}{
{
fixture: "fixtures/grades/empty.csv",
student: "Jane",
want: Gradebook{},
name: "EmptyFixture",
},
{
fixture: "fixtures/grades/valid.csv",
student: "Jane",
want: Gradebook{
Record{
student: "Jane",
subject: "Chemistry",
grade: "A",
},
Record{
student: "Jane",
subject: "Algebra",
grade: "A",
},
},
name: "ValidFixtures",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gradebook, err := NewGradebook(tc.fixture)
if err != nil {
t.Fatalf("Cannot create gradebook: %v", err)
}
got := gradebook.FindByStudent(tc.student)
for idx, gotGrade := range got {
wantedGrade := tc.want[idx]
if gotGrade != wantedGrade {
t.Errorf("Expected: %v, got: %v", wantedGrade, gotGrade)
}
}
})
}
}
In this test function, we have defined two test cases: the first one uses the
empty.csv
fixture, while the other uses the valid.csv
fixture. By looking
at the test cases, it is clear what we expect to get from each one. For
example, when working with the empty CSV, we hope to get a blank grade book -
no grades, no grade book. But, on the other hand, when working with the
valid.csv
we expect to get a Gradebook
, with all student grades specified.
The test function does not have any magic. It merely builds a Gradebook
using
the NewGradebook
function and the fixture file. Then, we invoke the
FindByStudent
function on the Gradebook
, and we make sure that all of the
grades that we got are the ones we expected.
If we run the test, we’ll get an output looking like this:
$ go test -v -run=TestFindByStudent
=== RUN TestFindByStudent
=== RUN TestFindByStudent/EmptyFixture
=== RUN TestFindByStudent/ValidFixture
--- PASS: TestFindByStudent (0.00s)
--- PASS: TestFindByStudent/EmptyFixture (0.00s)
--- PASS: TestFindByStudent/ValidFixture (0.00s)
PASS
ok _/Users/Ilija/Documents/fixtures 0.004s
The tests pass - building the Gradebook
s with the fixtures worked well, so we
could range
over the test cases and test our expectations.
Tidying up our tests #
Looking at both test functions that we wrote at the beginning of the t.Run
blocks, we can notice that we have to create a new Gradebook
using the
NewGradebook
builder function. In essence, this is the test setup in these
two test functions - we have to have an instance of the Gradebook
type to run
our tests.
When we use fixtures, the failure to use a fixture can mean that we can’t run the tests - they depend on the fixture files being available and usable. If the fixture renders to be unusable, we have to stop the tests further and bail out with an error.
It is a quick win to extract a test helper to use in the test setup for such reasons. We can extract all error handling for loading the fixture and test setup outside of the test functions. Let’s create a small function that will do just that:
func buildGradebook(t *testing.T, path string) *Gradebook {
gradebook, err := NewGradebook(path)
if err != nil {
t.Fatalf("Cannot create Gradebook: %v", err)
}
return &gradebook
}
The buildGradebook
is simply a wrapper around the call to NewGradebook
,
with one key difference: if a Gradebook
cannot be produced using
NewGradebook
it will mark the test as failed. Signaling the failure is done
using t.Fatalf
, where instead of returning an empty Gradebook
, we
immediately make the test fail. In other words: being unable to create a
Gradebook
is an unrecoverable error. A nice side-effect is that the caller
function of buildGradebook
does not need to handle the error that might be
returned from NewGradebook
- that will all be handled by buildGradebook
.
If we revisit our TestFindByStudent
function now, it will not have changed
much. Still, it will contain the improvements coming from the buildGradebook
function:
func TestFindByStudent(t *testing.T) {
cases := []struct {
fixture string
student string
want Gradebook
name string
}{
{
fixture: "fixtures/grades/empty.csv",
student: "Jane",
want: Gradebook{},
name: "EmptyFixture",
},
{ fixture: "fixtures/grades/valid.csv",
student: "Jane",
want: Gradebook{
Record{
student: "Jane",
subject: "Chemistry",
grade: "A",
},
Record{
student: "Jane",
subject: "Algebra",
grade: "A",
},
},
name: "ValidFixture",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gradebook := buildGradebook(t, tc.fixture)
got := gradebook.FindByStudent(tc.student)
for idx, gotGrade := range got {
wantedGrade := tc.want[idx]
if gotGrade != wantedGrade {
t.Errorf("Expected: %v, got: %v", wantedGrade, gotGrade)
}
}
})
}
}
If we would remove any of the fixture files, we will see how the test will be
marked as failed due to the t.Fatal
invocation:
$ rm testdata/grades/valid.csv # We remove the fixture
$ go test ./... -count=1 -v -run=TestFindByStudent
=== RUN TestFindByStudent
=== RUN TestFindByStudent/ValidFixture
=== RUN TestFindByStudent/EmptyFixture
--- FAIL: TestFindByStudent (0.00s)
--- FAIL: TestFindByStudent/ValidFixture (0.00s)
grades_test.go:8: Cannot create Gradebook: open testdata/grades/valid.csv: no such file or directory
--- PASS: TestFindByStudent/EmptyFixture (0.00s)
FAIL
FAIL _/Users/Ilija/Documents/fixtures 0.004s
By having another function that takes care of building the Gradebook
, we can
offload the complexity of the missing fixtures outside of the tests themselves.
While these concepts are simple, they’re powerful as they lead to cleaner tests
with local test functions that are easy to maintain.
EDIT October 8, 2019: As Andreas SchrΓΆpfer
suggested in the comments, it’s more idiomatic Go when the function receives a
io.Reader
instead of a file path. I have updated the example code and the
article to reflect that. Thanks Andreas!