Writing Tests in Go
Decide what test to write
Do test all your business decisions
Test all your code that is testable. Prioritize testing the code that (a) is important to work correctly (where confidence needs to be high) or (b) has become too complicated (where confidence has dropped too low).
Don’t test I/O and side effects
“But Günther”, I hear you saying, “what if there is a bug in these I/O parts”? Well - that’s why you need to keep them tiny and stare at them very long in a code review, to make sure they work without a test. It sounds heretic to leave this untested, but the alternative is to keep the I/O and other program logic together. That’ll easily make your more regular program logic hard to test, and that’s often not worth the trade off. After all, our goal is to increase confidence, not to reach full line coverage.
Special case: There are also some types of I/O which don’t affect your program logic, such as logging and incrementing performance counters. It’s ok to leave those untested without separating them out - these are battle proof APIs designed for quickly adding and removing them from your code, and they do not play a role for your programs correctness. It would give you little additional confidence to test them.
Structure your tests into Given/When/Then
Stick with the setup/exercise/verify style of tests. Some people call this also Given/When/Then or the “Four Phase test” (counting tear-down as separate phase).
It does not matter what you call this pattern, but “Given/When/Then” tends to read nice in comments. Delimiting the section explicitly with comments is optional, but serves another useful purpose: You cannot use helper functions that span multiple of these purposes. (Those helper functions would be bad style.)
func TestSpellOut(t *testing.T) {
// Given
s := speller.New("en")
// When
got := s.SpellOut(42)
// Then
want := "forty-two"
if got != want {
t.Errorf("SpellOut(%q); got %q, want %q", 42, got, want)
}
}
Test helpers
Test helper functions should be the primary way to share functionality between tests.
In particular, resist the temptation to extract the whole test into a
helper method, so that you can call it with differing parameters. The
way this is done in Go instead is with table-driven
tests
and
t.Run()
if needed.1
Setup helpers
- It accepts a
testing.TB
2 as first parameter. - It calls
t.Helper()
at the start. - It may not return an error, but will call
t.Fatal()
on error.- It is ok to fail early in setup helpers.
- Tests don’t need to check errors manually, making them easier to read.
- If it sets up state that needs to be undone after the test, it calls
t.Cleanup()
.
This is a helper method for setting up a Speller
object from the
previous example:
func setUpSpeller(t testing.TB, path string) speller.Speller {
t.Helper()
s, err := speller.Load(path)
if err != nil {
t.Fatalf("Could not set up speller: speller.Load(%q): %v",
path, err)
}
t.Cleanup(s.Close)
return s
}
Exercise helpers
It’s good practice to exercise the component under test directly without such a helper. Using the component directly is a good test for its API’s usability and will often uncover small possibilities for API improvements in the process.
Verification helpers
While I don’t necessarily agree with that, the next best thing you can
do within the bounds of this style is to extract helpers that do the
necessary comparisons for you, if necessary. The ground rule I use is:
The if
should be part of the top-level test function, but the
condition in that check can call the helper.
- These helpers do not need to have
testing.TB
passed in - they are just regular utility functions. - They should probably return a boolean, or other object representing
the result of the check (like
cmp.Diff
)
General advice on writing tests
A lot of general advice from other testing frameworks is applicable to Go too.
Make each test exercise a specific narrow scenario
Each test should exercise a specific and narrow scenario. There should be little overlap between tests. Any bug introduced in the code should ideally only break a single test, which has narrow focus and is related to the broken functionality.
Start with the assertion
Start writing tests with the assertion, and work from there upwards by filling out the missing variables that aren’t defined yet. This approach helps me to focus each test around one specific and narrow behaviour that I want to test.
One test for the happy path, plus one for each error
With simple functions with inputs, outputs and errors, I like to have one main test for the happy path, plus one for each error, whose test input is based on the one for the happy path, with a modification.
See my article on Shared base fixtures for examples.
Avoid TestMain
, use setup helper functions
If you don’t know what TestMain
is, good!
TestMain
is a way to share common test setup and teardown between
multiple test cases, without them calling a setup helper explicitly.
In most cases, this is better done with a setup helper function as
described above.
There is one case where TestMain
is acceptable, which is the case
where the test setup is so expensive to run that it will be
significantly faster to only do it once.
-
t.Run()
is a way of structuring your test output, but it’ll also make sure that subtests can cancel witht.Fatal()
, without affecting the execution of other subtests. ↩︎ -
testing.TB
is an interface for the common methods intesting.T
andtesting.B
, so it can be used from tests and benchmarks alike. ↩︎