Testing in Go: Writing Practical Failure Messages
Table of Contents
All developers appreciate code that works, yet we spend much of our working hours debugging existing code. When fixing existing code, what our test failures communicate is paramount to the debugging experience we have.
That’s why in this article we will look at what it means to write a meaningful test failure message. We will look at its structure and how we can use some simple techniques to improve our test failure messages. Whether it’s a colleague of ours, or our future selves, a great error message can make people’s lifes easier and they’ll be grateful for it.
Anatomy of a test case #
Before we continue on exploring failure messages and their traits, we should
create a function that we can test. When talking about test failures without
having some actual code to test would be a waste of our time. Let’s start
simple - a very small function Max
that receives a slice of int
s as
arguments and returns the biggest int
:
// max.go
package main
func Max(numbers []int) int {
var max int
for _, number := range numbers {
if number > max {
max = number
}
}
return max
}
The function traverses the slice of int
s and it compares each of its
elements, assigning the larger one to the max
variable. At the end, it
returns the max
as the result. We will use table-driven tests to test this
function. After that, we can disuss its anatomy.
func TestMax(t *testing.T) {
cases := []struct{
input []int
expected int
}{
{
input: []int{1, 2, 3, 4, 5},
expected: 100,
},
{
input: []int{-1, -2, -3, -4, -5},
expected: 100,
},
{
input: []int{0},
expected: 1,
},
}
for _, c := range cases {
actual := Max(c.input)
expected := c.expected
if actual != expected {
t.Fatalf("Expected %d, got %d", expected, actual)
}
}
}
Running the tests will produce the following output:
› go test
--- FAIL: TestMax (0.00s)
max_test.go:31: Expected 100, got 5
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing 0.004s
Great, now that we have something working, we can dissect our test function. The function has three main parts:
-
The setup: in our example we use table-driven tests, where each of the structs has an
input
and anexpected
output. When theinput
is passed to the function, the expected result is theexpected
attribute of the case struct. When testing more complicated functions, this is where we would do any other setup that would be mandatory for the test to run. Some examples are loading more complicated fixtures (from fixture files) or opening a connection to a database and querying some data. -
Building the assertion: this is where the testing happens. In table driven tests, this usually means that we have some sort of a
for
loop that traverses thestruct
s and passes theinput
to the function under test (in this caseMax
). Then, we compare theoutput
and theexpected
value, which will inform us if the function has passed the test. -
Reporting: when the assertion is false, meaning the result and the expected value are not the same, the test function has to report that something went wrong. This is where we will focus for the rest of this article.
Now that we understand the anatomy of a common test case, let’s open the discussion of what are the traits of a good and a bad test failure message.
Traits of an failure message #
To define the traits of an test failure message, let’s examine the output of the failed test from the previous section:
› go test -v
=== RUN TestMax
--- FAIL: TestMax (0.00s)
max_test.go:31: Expected 100, got 5
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing 0.004s
It has three segments:
- Test status (pass or fail)
- Function name that was run and failed
- Failure message(s)
Although the failure message is the one where we get most of the information about the failure of the test, we rely on the output as a whole to get useful information/context about the failure. In the failure message, we can see that it contains two parts: what was expected and what was received.
Is this good enough? I would say: yes and no.
Yes, because it succintly communicates what our test case expected, and what it received. No, because it does not provide any other context, although there is plenty of it in the test function. This hidden knowledge can hinder us when fixing the test.
So, back to the original question: what are the traits of a bad and a good test failure message?
Bad failure messages in failed test(s) hide data and behaviour from the programmer, obstructing them in fixing the failing test(s). Good failure messages, on the other hand, contain just enough information so the programmer can make the test pass.
Here’s a list of data points that we could expose in our test failure messages:
- the line number of the failed test in the source file
- the source file of the test itself
- the expression that failed
- the left and right values in the equality comparison
- the surrounding state - values of the variables participating in the expression that failed
Keep in mind that these are just guidelines and there is no hard and fast rule. There have been times where my tests have included enough information, and still debugging and fixing them was hard. There are many times when a small refactoring exercise can do wonders for your functions - make sure you run the tests often and add some more as you go.
In theory such rich failure messages should be useful. But, how do we actually create them in practice? At the center of all this is a very simple and common technique - inspecting values of types.
Inspecting Primitive Types #
Golang has a quite a list of primitive types and always having a value is a trait they share. Even variables that were only declared have a value without explicitly defining it, called zero values. Inspecting primitive types in a test failure means we only have to look at their value.
Unsurprisingly, this is simple in Golang. To inspect a value of a primitive
type we only need to print its value using a comibnation of fmt
’s Sprintf
and Println
functions. Sprintf
requires a format verb, which will be %v
(stands for value
).
Here’s a simple program that inspects a few primitive types, priting their values:
package main
import "fmt"
func main() {
fmt.Println(fmt.Sprintf("Boolean: %v", true))
fmt.Println(fmt.Sprintf("Integer: %v", 42))
fmt.Println(fmt.Sprintf("String: %v", "Hello world"))
fmt.Println(fmt.Sprintf("Float: %v", 3.14))
}
The main
function prints four different values, each of them of a primitive
type. We’ve added the type names in the printing statements to aid visual
inspection. If we would run it, the program would produce the following output:
› go run whatever.go
Boolean: true
Integer: 42
String: Hello world
Float: 3.14
This is a very simple way to inspect any values of primitive type - pass them
to Sprintf
(with the %v
formatting verb) and send its output to Println
.
If we look at our tests from earlier, you will notice that we actually already
use it:
// Snipped...
for _, c := range cases {
actual := Max(c.input)
if actual != c.expected {
out := fmt.Sprintf("Running: Max(%v)\n", c.input) +
fmt.Sprintf("Argument: %v \n", c.input) +
fmt.Sprintf("Expected result: %d\n", c.expected) +
fmt.Sprintf("Actual result: %d\n", actual)
t.Fatalf(out)
}
}
We print the input and the argument using the %v
verb and we print the
expected and actual results using the %d
– denoting a base 10 integer
representation of the value. Using this approach we are able to send any output
to the person battling the failed specs. Here are some other formatting verbs
that we can use:
Boolean:
- %t the word true or false
Integer:
- %b base 2
- %c the character represented by the corresponding Unicode code point
- %d base 10
- %o base 8
String:
- %s the uninterpreted bytes of the string or slice
- %q a double-quoted string safely escaped with Go syntax
There are more verbs available in the fmt
package, head over to the
documentation to check them out.
Inspecting Custom Types #
When it comes to inspecting state of custom types, things can easily get hairy. All custom types start simple, with few attributes. But as codebases grow, the size and complexity of custom types can (read: will) grow. A test that once used a very simple type to check a behaviour of certain function, now might produce an output containing a huge struct.
So, in such cases how can we show only the relevant values when the tests fail?
Just like with the primitive types, printing the internals of a custom type is
a simple exercise in Go - using the fmt
package’s Sprintf
, using the verbs
%v
and %+v
.
To look at Sprintf
’s workings in combination with structs, we will create a
type Person
. It has two attributes age
(with type int64
) and name
(with
type string
):
type Person struct {
age int64
name string
}
The Person
will implement a function older
which will return a bool
ean,
when a Person
is older than another Person
. It will do that by comparing
the ages of the two Person
structs:
func (p *Person) older(other *Person) bool {
return p.age > other.age
}
Having the topic of testing in the focus here, let’s also add a test function
for the older
function:
func TestOlder(t *testing.T) {
cases := []struct {
person1 *Person
person2 *Person
expected bool
}{
{
person1: &Person{
name: "Jane",
age: 22,
},
person2: &Person{
name: "John",
age: 23,
},
expected: false,
},
{
person1: &Person{
name: "Michelle",
age: 55,
},
person2: &Person{
name: "Michael",
age: 40,
},
expected: true,
},
{
person1: &Person{
name: "Ellen",
age: 80,
},
person2: &Person{
name: "Elliot",
age: 80,
},
expected: true,
},
}
for _, c := range cases {
actual := c.person1.older(c.person2)
if actual != c.expected {
out := fmt.Sprintf("Running: older(%v)\n", c.person2) +
fmt.Sprintf("Argument: %v \n", c.person2) +
fmt.Sprintf("Expected result: %t\n", c.expected) +
fmt.Sprintf("Actual result: %t\n", actual)
t.Fatalf(out)
}
}
}
As you can see, we use the same table-driven tests approach and similar formatting for the failure output. If we run the test, we should expect the last test case to fail, because the two persons that we will compare are not older than the other.
› go test
--- FAIL: TestOlder (0.00s)
person_test.go:57: Running: older(&{80 Elliot})
Argument: &{80 Elliot}
Expected result: true
Actual result: false
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing_person 0.004s
Here we can observe a similar output to the one we had in the previous
examples. The only difference is in the way the struct is formatted: while we
see the values of its attributes, we cannot see the names of the attributes. To
fix this, we can apply the %+v
formatting verb, which will produce the
following output:
› go test
--- FAIL: TestOlder (0.00s)
person_test.go:57: Running: older(&{age:80 name:Elliot})
Argument: &{age:80 name:Elliot}
Expected result: true
Actual result: false
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing_person 0.004s
The %+v
formatting verb when printing structs adds field names to the values,
so it is easier for us to understand the state of the struct. Obviously, this
way of printing all attributes can be problematic when we are facing big
structs.
In such cases, there’s a neat trick that we can use: defining a String
function for the struct. By having a String
method, our type will implicitly
implement the Stringer
interface. A Stringer
is a type that can describe
itself as a string. The fmt
package (and many others) look for this interface
to print values.
If we would implement a String
function for our type, our custom type will be
able to describe itself. The cool part is that we could have the String
function in the test file itself (person_test.go
), or in the file with the
type definition (person.go
). Wherever Golang finds the function it will use
it when printing the struct.
// person_test.go
func (p *Person) String() string {
out := fmt.Sprintf("\nAge: %d\n", p.age) +
fmt.Sprintf("Name: %s\n", p.name)
return out
}
By having the String
function, we can change the approach we use in the
failure reporting in the test itself:
if actual != c.expected {
out := fmt.Sprint("Argument: ", c.person2) +
fmt.Sprintf("Expected result: %t\n", c.expected) +
fmt.Sprintf("Actual result: %t\n", actual)
t.Fatalf(out)
}
Note that in the line where we build the output we use just fmt.Sprint
with
the argument of c.person2
, which is the struct itself. We do not specify what
attributes will be printed, the String
function takes care of everything.
The output will be:
› go test
--- FAIL: TestOlder (0.00s)
person_test.go:62: Argument:
Age: 80
Name: Elliot
Expected result: true
Actual result: false
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing_person 0.004s
By implementing the Stringer
interface, we let our Golang’s fmt
package
take care of the printing.
But, could we take this idea further? Imagine if the structs that we use to build our table-driven tests could actually describe themselves?
Self-describing test cases #
From our test that we wrote earlier, let’s extract a type (TestCase
) and use
it in our TestOlder
test function:
type TestCase struct {
person1 *Person
person2 *Person
expected bool
}
func TestOlder(t *testing.T) {
cases := []TestCase{
{
person1: &Person{
name: "Ellen",
age: 80,
},
person2: &Person{
name: "Elliot",
age: 80,
},
expected: true,
},
}
for _, c := range cases {
actual := c.person1.older(c.person2)
if actual != c.expected {
out := fmt.Sprint("Argument: ", c.person2) +
fmt.Sprintf("Expected result: %t\n", c.expected) +
fmt.Sprintf("Actual result: %t\n", actual)
t.Fatalf(out)
}
}
}
(I also removed two of the test cases for brewity.)
This will not change the output of the test - we extracted a type that we
defined inline in the previous versions of the test. If we think about the
failure output that our test function produces, couldn’t our new type
TestCase
implement the Stringer
interface too? What if we define a String
function on TestCase
which will print out every test failure in the same,
structured format, standardized across all our test functions in this test
file?
Here’s an example of a String
function that TestCase
can implement:
func (tc TestCase) String() string {
out := fmt.Sprint("Person 1: ", tc.person1, "\n") +
fmt.Sprint("Person 2: ", tc.person2, "\n") +
fmt.Sprintf("Expected result: %t\n", tc.expected)
return out
}
To put this function in action, we need to make a small modification to the assertion in the test function:
for _, c := range cases {
actual := c.person1.older(c.person2)
if actual != c.expected {
out := fmt.Sprint(c) +
fmt.Sprintf("Actual result: %t", actual)
t.Fatalf(out)
}
}
And the output, on failure, will look like:
› go test
--- FAIL: TestOlder (0.00s)
person_test.go:71: Person 1:
Age: 80
Name: Ellen
Person 2:
Age: 80
Name: Elliot
Expected result: true
Actual result: false
FAIL
exit status 1
FAIL _/Users/Ilija/Documents/testing_person 0.004s
Now, we can see very clearly what was the expected result, the actual result and the state of the variables that are in play for the particular test case. We can improve the formatting of the output even more, but for our purposes this will do fine.
I hope that this makes it clear that tests are just code. If we use Golang’s
power to construct test cases as structs and use them in tests, we can also use
other Golang goodies like the Stringer
interface and make our test cases
report their failures better.
Simplicity and verbosity #
Before we wrap up this walkthrough that we embarked on, there’s one last thing that I would like us to discuss. I would go on a limb and say that you are probably asking yourself one of these two questions already:
Is there a simpler way to do this? Why can’t we let a testing library or framework take care of the failures messages for us?
The answer would be: sure, you can totally do that.
But before you opt-in to use a testing library, I want to you understand few things so you can make better choices for your projects in the future.
- Go is simple by design. If you look at the The Go Programming Language Specification you can see how simple and short it is. This is a feature of the language, not a shortcoming.
- Go ships with a lightweight testing framework. It has out of the box support for writing and running test files, which many other languages do not have.
- Tests in Go are just code, so they should be as simple as any Go code. Naming a test file, writing the tests and running them should all be simple actions. Analogous to that, a test assertion should be a very simple comparison between two values.
Having these three principles in mind, I hope you can appreciate why writing some failures messages in Golang tests can be a verbose but simple thing to do. As Rob Pike, one of Golang’s authors, says in his “Go Proverbs” talk:
Clear is better than clever… There are languages where cleverness is considered a virtue. Go doesn’t work that way. Especially when you’re learning Go out of the box, you should be thinking of writing simple and clear code. That has a lot to do with maintability, stability and the ability of other people to read your code.