Testing Go Better

How To Create Testable Go Code

gopher

Testing your code is a good idea, but sometimes you might be testing the wrong thing, or even worse, testing nothing. Sometimes it might not be clear how to test something, or you might think something is not testable at all. This article is here to show you some tricks on making your code more testable, and a thought process you can apply to testing your code.

Before we start let's get one thing out of the way: You don't need a third party testing framework. We have all the tools we need right here in the Go standard lib, and this guide will be using those tools exclusively. You certainly don't need to import an entire "assert" library to avoid writing one if statement. See: https://golang.org/doc/faq#testing_framework if you disagree with me.

Let's start with a simple example of a test. Here is a function that finds the average of a slice of floats:

go
1
func average(values []float64) float64 {
2
var total float64 = 0
3
for _, value := range values {
4
total += value
5
}
6
return total / float64(len(values))
7
}
8

This is a perfectly testable function. We know what the output should be given any input, so we can test it very easily:

go
1
func Test_Average(t *testing.T) {
2
result := average([]float64{3.5, 2.5, 9.0})
3
if result != 5 {
4
t.Errorf("for test average, got result %f but expected 5", result)
5
}
6
}
7

This is a valid, and useful, test. But it only tests one option. Testing multiple inputs can get messy, unless you organise well. It is ideal to create an array of test cases:

go
1
func Test_Average(t *testing.T) {
2
tests := []struct{
3
name string
4
input float64
5
expectedResult float64
6
}{
7
{
8
name: "three normal values",
9
input: []float64{3.5, 2.5, 9.0},
10
expectedResult: 5,
11
},
12
{
13
name: "handle zeroes",
14
input: []float64{0, 0},
15
expectedResult: 0,
16
},
17
{
18
name: "handle one value",
19
input: []float64{15.3},
20
expectedResult: 15.3,
21
},
22
}
23
24
for _, test := range tests {
25
result := average(test.input)
26
if result != test.expectedResult {
27
t.Errorf(
28
"for average test '%s', got result %f but expected %f",
29
test.name,
30
result,
31
test.expectedResult,
32
)
33
}
34
}
35
}
36

So we can now handle many test cases elegantly. Unfortunately not all code is going to be as simple as this. Let's consider if the function was like this:

go
1
func averageFromWeb() (float64, error) {
2
resp, err := http.DefaultClient.Get("http://our-api.com/numbers")
3
if err != nil {
4
return 0, err
5
}
6
7
values := []float64{}
8
if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {
9
return 0, err
10
}
11
12
var total float64 = 0
13
for _, value := range values {
14
total += value
15
}
16
return total / float64(len(values))
17
}
18

Now our code is hitting an API to get the values. We could still run the same test on this code, but we would be relying on that API to provide us with the same values every single time. We would also be limited to just a single test case, even if we could rely on the API in that way. Not to mention it's not ideal to be relying on a connection to our API, or to hit our API every time we test. Let's avoid racking up unnecessary cloud bills, shall we?

It's at this point that we need to ask ourselves, what is it that we are actually trying to test here? Are we trying to test that the Golang HTTP library can make HTTP calls? No. We didn't write that code. We want to test all of the other code around it that we wrote. That is, calculating the average of the values.

So how do we call this function in a test, but also avoid testing the HTTP call? We need to mock it. But before we can do that, we need to make it mockable. This will require some tricky setup. The first thing we do is turn the HTTP part of the function into a seperate function:

go
1
func getValues() (float64, error) {
2
resp, err := http.DefaultClient.Get("http://our-api.com/numbers")
3
if err != nil {
4
return 0, err
5
}
6
7
values := []float64{}
8
if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {
9
return 0, err
10
}
11
12
return values, nil
13
}
14

Now our original function could use this function. But that doesn't make the code mockable just yet. The HTTP call still happens. We need to have a mock version of this function that doesn't call the API, but just returns some values we choose:

go
1
function getValues() (float64, error) {
2
return []float64{1.0, 2.5}, nil
3
}
4

The issue with this is, when we run this code for real, we want the real version to run. We want the HTTP call to happen. But when we test it, we want the mock version of the function to run. We need to be able to run this function with two different setups. A real setup, and a mock setup.

This is where an interface comes in handy. The averageFromWeb function doesn't need to know what version of the getValues function is running, it just needs to know the output types. So let's make that an interface:

go
1
type ValueGetter interface {
2
GetValues() ([]float64, error)
3
}
4

Now, we could set up our function so that we pass in the implementation of this interface, like so:

go
1
func averageFromWeb(valueGetter ValueGetter) (float64, error) {
2
values, err := valueGetter.GetValues()
3
if err != nil {
4
return 0, err
5
}
6
7
var total float64 = 0
8
for _, value := range values {
9
total += value
10
}
11
return total / float64(len(values))
12
}
13

This works for this scenario, but it's not going to scale for us in the future. Say we had 20 functions that all call different HTTP services, or databases, etc. We don't want to have to pass the implementations for all of those services and database around everywhere we go. We will have spaghetti code in no time. To make this elegant, we can create a "service" struct that can hold our implementation details, and make our function a method on that struct:

go
1
type service struct {
2
valueGetter ValueGetter
3
}
4
5
func (s service) averageFromWeb() (float64, error) {
6
values, err := s.valueGetter.GetValues()
7
if err != nil {
8
return 0, err
9
}
10
11
var total float64 = 0
12
for _, value := range values {
13
total += value
14
}
15
return total / float64(len(values))
16
}
17

Now we're talking. The last two things we need are our implementations of ValueGetter. Here they are:

go
1
// The real implementation //
2
3
type httpValueGetter struct {}
4
5
func (h httpValueGetter) GetValues() (float64, error) {
6
resp, err := http.DefaultClient.Get("http://our-api.com/numbers")
7
if err != nil {
8
return 0, err
9
}
10
11
values := []float64{}
12
if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {
13
return 0, err
14
}
15
16
return values, nil
17
}
18
19
// The mock implementation //
20
21
type mockValueGetter struct {
22
values []float64
23
err error
24
}
25
26
func (m mockValueGetter) GetValues() (float64, error) {
27
return m.values, m.err
28
}
29

Now we have a nicely mockable, and scalable service model for our function. In the real version of our code we'll initialise this service with the real version of the ValueGetter like so:

go
1
func main() {
2
service := service{valueGetter: httpValueGetter{}}
3
4
average, err := service.averageFromWeb()
5
if err != nil {
6
panic(err)
7
}
8
9
fmt.Println(average)
10
}
11

And our test will look like this:

go
1
func Test_Average(t *testing.T) {
2
testError := fmt.Errorf("an example error to compare against")
3
4
tests := []struct{
5
name string
6
input float64
7
err error
8
expectedResult float64
9
expectedErr error
10
}{
11
{
12
name: "three normal values",
13
input: []float64{3.5, 2.5, 9.0},
14
expectedResult: 5,
15
},
16
{
17
name: "handle zeroes",
18
input: []float64{0, 0},
19
expectedResult: 0,
20
},
21
{
22
name: "handle one value",
23
input: []float64{15.3},
24
expectedResult: 15.3,
25
},
26
{
27
name: "error case",
28
input: []float64{3.5, 2.5, 9.0},
29
err: testError,
30
expectedErr: testError,
31
},
32
}
33
34
for _, test := range tests {
35
service := service{valueGetter: mockValueGetter{
36
values: test.input,
37
err: test.err,
38
}}
39
40
result, err := service.averageFromWeb()
41
42
if err != test.expectedErr {
43
t.Errorf(
44
"for average test '%s', got error %v but expected %v",
45
test.name,
46
err,
47
test.expectedErr,
48
)
49
}
50
51
if result != test.expectedResult {
52
t.Errorf(
53
"for average test '%s', got result %f but expected %f",
54
test.name,
55
result,
56
test.expectedResult,
57
)
58
}
59
}
60
}
61

Fantastic! We're creating the mockValueGetter using the same input values from the test we made earlier, and also testing the error case.

This approach can be used to easily mock out any part of the code, including HTTP calls, database queries, and more. There is also an added advantage of making the code more modular and maintainable. Try this out on your own codebase and see how much more of your code you can test!