Test Fixtures in Golang
- TL;DR
- Overview
- Keeping Test Data Under
testdata
- Accessing Test Data from a Separate Directory
- Creating Complex Fixtures using Functional Builders
- References
TL;DR⌗
- For fixtures that need to be accessed from a single package, stick them in a
testdata
folder within the package - For fixtures that must be kept outside the package or accessed by multiple packages, create a test-helpers package and anchor their paths with
runtime.Caller(0)
- For fixtures that are complex and have a lot of variations, use the functional builder pattern
Overview⌗
A test fixture is any kind of data needed for our tests to run. These can be things like files, binary data, mock database responses, or test structs.
In our test suite, we need a clear, consistent means of accessing our various test fixtures. While we could jam these into our tests as strings or inline structs, this is cumbersome and leads to potential mistakes. For example, let’s say we needed to test this (trivial) unmarshalling code:
type Pet struct {
ID int64 `json:"id"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
}
func ReadPet(b []byte) (*Pet, error) {
f := &Pet{}
if err := json.Unmarshal(b, f); err != nil {
return nil, err
}
return f, nil
}
We could embed the test JSON object inline like this:
func TestReadPet_Inline(t *testing.T) {
j := `
{
"id": 42,
"name": "Foo",
"tag": "dog"
}
`
result, err := petstore.ReadPet([]byte(j))
if err != nil {
t.Errorf("error while unmashalling: %v", err)
}
if result.ID != 42 && result.Name != "foo" {
t.Errorf("%v: output doesn't match expected result", result)
}
}
While this may work, it looks clunky when put inline in our test. Moreover, our IDE doesn’t recognize this as a JSON object, which makes it harder to edit or catch formatting errors.
Binary data exacerbates this problem. While possible to keep it as strings via an encoding like base64, it obscures what the test data represents.
Keeping Test Data Under testdata
⌗
The simplest solution is to keep all the test data in the same directory as the code itself under a special folder called testdata
. This is a special folder ignored by the compiler, but still accessible for reading during tests. For example, we could keep the above sample JSON in its own file:
{
"id": 42,
"name": "Foo",
"tag": "dog"
}
Then read it during our test:
func TestReadPet_TestData(t *testing.T) {
b, err := ioutil.ReadFile("testdata/sample.json")
if err != nil {
t.Fatal(err)
}
result, err := petstore.ReadPet(b)
...
For more information, see Dave Cheney’s blog post about test fixtures in go.
Accessing Test Data from a Separate Directory⌗
Sometimes the files we need must be kept in a separate directory from our code. This may be because multiple packages’ tests need to access them, or because things outside our code depend on them. For example, let’s say we have a JSON schema spec living in /api/pet-schema.json
:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"tag": {
"type": "string"
}
}
}
We could reference this file in our tests via a relative path such as ../api/pet-schema.json
. However, this couples our test to a specific project layout. If we changed the file path of either pet-schema.json
or any of our modules, this could break our tests.
Another strategy is using a separate test helpers module that anchors the relative directory of our test data using runtime.Caller(0)
. This returns information about the current call stack, including the filename running the runtime.Caller
command. We could create a file called /test/test_helpers.go
, then anchor the location of our schema relative to it:
func GetPetSchema() (*jsonschema.Schema, error) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
return nil, ErrPathDiscovery
}
path := path.Join(path.Dir(filename), "..", "api", "pet-schema.json")
...
}
We can then use our test helpers module like any other module:
func TestSchemaValidation(t *testing.T) {
schema, err := test.GetPetSchema()
if err != nil {
t.Fatal(err)
}
pet := petstore.Pet{
ID: 42,
Name: "foo",
}
errs := schema.Validate(context.TODO(), pet).Errs
if len(*errs) > 0 {
t.Errorf("got the following errors while validating data: %v", errs)
}
}
Moreover, this works no matter where the calling code lives:
func TestSubpackage(t *testing.T) {
_, err := test.GetPetSchema()
if err != nil {
t.Fatal(err)
}
}
Creating Complex Fixtures using Functional Builders⌗
Sometimes it’s not enough to have a single, static variation of a test object. For example, consider the following validation code:
func (p *Pet) Validate() error {
if p.ID < 0 {
return fmt.Errorf("%w: 'id' must be greater than 0", ErrValidationFail)
}
if p.Name == "" {
return fmt.Errorf("%w: 'name' must not be empty", ErrValidationFail)
}
if len(p.Name) > 100 {
return fmt.Errorf("%w: 'name' must be less than 100 characters", ErrValidationFail)
}
if p.Tag != "" {
validTags := []string{
"cat",
"dog",
}
hasValidTag := false
for _, t := range validTags {
if p.Tag == t {
hasValidTag = true
}
}
if !hasValidTag {
return fmt.Errorf("%w: 'tag' must be one of the following: [%s]",
ErrValidationFail, strings.Join(validTags, ","))
}
}
return nil
}
Using a table-driven test, we could test many variations of the petstore.Pet
object like so:
func TestValidate_NoBuilder(t *testing.T) {
tests := []struct {
name string
pet *petstore.Pet
wantErr bool
}{
{
name: "Validates a valid pet",
pet: &petstore.Pet{
Name: "Foo",
ID: 64,
Tag: "dog",
},
wantErr: false,
},
{
name: "Validates if tag is empty",
pet: &petstore.Pet{
Name: "Foo",
ID: 64,
Tag: "",
},
wantErr: false,
},
{
name: "Errors if ID less than 0",
pet: &petstore.Pet{
Name: "Foo",
ID: -200,
Tag: "dog",
},
wantErr: true,
},
{
name: "Errors if name is empty",
pet: &petstore.Pet{
Name: "",
ID: 64,
Tag: "dog",
},
wantErr: true,
},
{
name: "Errors if name is greater than 100 characters",
pet: &petstore.Pet{
Name: strings.Repeat("A", 101),
ID: 64,
Tag: "dog",
},
wantErr: true,
},
{
name: "Errors if tag is invalid",
pet: &petstore.Pet{
Name: "Foo",
ID: 64,
Tag: "turkey",
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if err := tt.pet.Validate(); (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
While this is workable for testing all variations of the Pet
object, it’s difficult to determine what’s changing between each test. Most of the fields in each pet
only contain default data for the sake of the test. In the “Errors if ID less than 0” test, we don’t particularly care what Name
and Tag
are: all we care about is that ID
is set to a negative number.
To solve this, we can instead use Dave Cheney’s functional builder pattern. This allows us to use a sane default for all tests entries, while allowing each one to customize the resulting fixture as it sees fit. We can implement this pattern like so:
func getTestPet(options ...petOption) *petstore.Pet {
p := &petstore.Pet{
Name: "Foo",
ID: 42,
Tag: "dog",
}
for _, option := range options {
option(p)
}
return p
}
type petOption func(*petstore.Pet)
func withTag(tag string) petOption {
return func(p *petstore.Pet) {
p.Tag = tag
}
}
func withName(name string) petOption {
return func(p *petstore.Pet) {
p.Name = name
}
}
func withID(id int64) petOption {
return func(p *petstore.Pet) {
p.ID = id
}
}
Then our tests become simplified to this:
func TestValidate_Builder(t *testing.T) {
tests := []struct {
name string
pet *petstore.Pet
wantErr bool
}{
{
name: "Validates a valid pet",
pet: getTestPet(),
wantErr: false,
},
{
name: "Validates if tag is empty",
pet: getTestPet(withTag("")),
wantErr: false,
},
{
name: "Errors if ID less than 0",
pet: getTestPet(withID(-200)),
wantErr: true,
},
{
name: "Errors if name is empty",
pet: getTestPet(withName("")),
wantErr: true,
},
{
name: "Errors if name is greater than 100 characters",
pet: getTestPet(withName(strings.Repeat("A", 101))),
wantErr: true,
},
{
name: "Errors if tag is invalid",
pet: getTestPet(withTag("turkey")),
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if err := tt.pet.Validate(); (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
Now it becomes immediately obvious what’s changing in each test! In the “Errors if ID less than 0” test, the only thing we see is that our default pet’s ID is set to -200 via withID(-200)
. This helps improve test readability and maintainability.
References⌗
All example code can be found here: https://gitlab.com/hackandsla.sh/blog-examples/-/tree/master/2020-11-23-golang-test-fixtures