Go _test packages

Published Oct 7, 2021

I feel very strongly that program testing should be done through public interfaces, and there needs to be a really good reason to do otherwise. One of the things I like about Go is that its tooling makes it easy for me to say that all testing for any package must be done with only the public interface.

How can that be? Aren’t there complicated things that should be tested but not made public? Yes, definitely! But there are two conventions that make this possible: _test packages and internal directories.

I’m writing this up because this pattern is something I used extensively when I started writing Go in late 2017, but I couldn’t find the place where I learned it. Even better, as I was telling a teammate that the Go documentation was gaslighting me, I learned that _test packages have been under-documented for literally years. There’s a single-sentence paragraph that describes them in the package docs for cmd/go (since this is a go feature, and not a language feature).

Test files that declare a package with the suffix “_test” will be compiled as a separate package, and then linked and run with the main test binary.

The internal pattern has a much better explanation in the same cmd/go docs. Dave Cheney, a familiar friend to Go learners, expands further.

The _test package recipe

When testing a package app/foo (say, with a file bar.go), create your test file with the _test.go suffix as usual. In this case, that would be app/foo/bar_test.go. However, instead of putting package foo at the top like you normally would, use package foo_test instead.

The foo_test package is now totally independent from foo (other than living in the same directory). If you want to use anything from foo in your tests (which you do, right?), you’ll need to import it just like its other consumers do.

This means that you’re forced to only interact with exported names. If something feels awkward in these tests, it will almost certainly feel awkward to consumers.

As far as I know, this is the only special case where a directory can contain two different package declarations.

Now in an internal directory

So now that we know how to write public interface tests, let’s go full “strict mode” and ban all private tests. Essentially, if it can’t be observed by consumers through the public interface, it must not be tested.

I wouldn’t expect anyone to actually take this stance. But I can tell you that I wrote Go in a team for 2 years and just realized that I don’t even have a naming convention for private test files that coexist with public ones. So you might be able to get pretty far!

Anything that’s complicated enough to test but not useful (or stable!) enough to be exported can move into an internal package. So the app/foo.complicated function could become app/foo/internal/utils.Complicated (used in foo as utils.Complicated). The app/foo/internal/utils package can only be imported by app/foo and its utils_test package, so it’s not really exported. And you’re now back to writing public tests (for an internal package) instead of private tests!

Discovering the package boundary

This works really well for “evolved abstractions”.

Let’s say app/foo seems to have developed a clear abstraction around Item. One way to tell that this has happened is if there’s a subset of names that include “Item”: type Item, func NewItem, func ParseItem, type ItemSet, etc. An item package seems useful, and it might even be helpful elsewhere. (Maybe it’s even good enough to open-source!) But it’s probably missing some things (documentation, good error messages, etc.) before it’s ready to be used generally throughout the codebase.

Move Item and friends to app/foo/internal/item! While things settle from that refactor, it’s easy to move things into or out of the item package. And with tests in the item_test package, you’ll uncover awkward constructions before you commit support for them.

In writing this, I realized I’m essentially rephrasing the same Dave Cheney post from before, but I hope the extra detail is useful along with his philosophy.

A full example

Here’s a code dump of what this looks like for a totally useful package in a totally real app I just made.

$ tree
.
├── go.mod
└── useful
    ├── internal
    │   └── splines
    │       ├── splines.go
    │       └── splines_test.go
    └── useful.go

3 directories, 4 files

go.mod:

module app

go 1.17

useful.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package useful

import (
	"app/useful/internal/splines"
)

// Let consumers spell the type of the exported field
type Spline = splines.Spline

type Data struct {
	Splines []Spline
}

func Load() (*Data, error) {
	s, err := splines.Reticulate(10)
	if err != nil {
		return nil, err
	}
	return &Data{Splines: s}, nil
}

useful/internal/splines/splines.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package splines

import (
	"errors"
)

type Spline struct {
	// Probably shouldn't be empty 🤷
}

var ErrTooManySplines = errors.New("too many splines")

func Reticulate(n int) ([]Spline, error) {
	if n > 5 {
		return nil, ErrTooManySplines
	}
	splines := make([]Spline, n)
	for i := 0; i < len(splines); i++ {
		s := Spline{}
		// Insert complicated initialization code here
		splines[i] = s
	}
	return splines, nil
}

useful/internal/splines/splines_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package splines_test

import (
	"app/useful/internal/splines"
	"errors"
	"testing"
)

func TestReticulate(t *testing.T) {
	t.Run("a few splines", func(t *testing.T) {
		s, err := splines.Reticulate(3)
		if err != nil {
			t.Errorf("expected no error, got %s", err.Error())
		}
		if len(s) != 3 {
			t.Errorf("expected 3 splines, got %d", len(s))
		}
	})

	t.Run("a lot of splines", func(t *testing.T) {
		_, err := splines.Reticulate(11)
		if err == nil {
			t.Fatal("expected ErrTooManySplines, got nil")
		}
		if !errors.Is(err, splines.ErrTooManySplines) {
			t.Errorf("expected ErrTooManySplines, got %s", err.Error())
		}
	})
}