5 tips for better unit testing in Golang

Russell Westbrook, Ben Simmons and Gopher do not practice their shooting (tests), hence their low shooting (coverage) percentage, resulting in bricks (bugs) thrown at the basket(project).

When writing code projects, developers will usually devote a large portion of their time for choosing the right frameworks, libraries, databases and other 3rd party components. One section that is often overlooked and neglected is the testing.

Proper testing actually make your project better because they encourages you to:

  1. Apply clean code: write short functions, handle a single task per function, etc.
  2. Write extendable and agnostic code through the use of abstractions, interfaces and mocks.
  3. Understand the business logic better by testing regular/edge cases and high coverage of these.
  4. Avoid legacy, long-untouched and/or unmaintainable code — tests will ease the process of maintaining and verifying changes to code so it doesn’t rot.
  5. Measure the performance of your code through benchmarks, load tests, etc.

While testing is obviously not invented or started by Golang, the language provides a rich standard library, conventions and external libraries that shorten your path towards a properly tested project.

Table Driven Testing

Test can quickly become unreadable, repetitive and overall annoying when the function you want to test is handling too many tasks, and especially when there are many different cases you want to test.

Let’s look at the following code that naively checks whether an NBA player had a good or bad game:

package main

import (
"fmt"
)

type Stats struct {
Name string
Minutes float32
Points int8
Rebounds int8
Assists int8
Turnovers int8
}

func main() {
s := Stats{Name: "Stef Kuri", Minutes: 25.1, Points: 21, Assists: 3, Turnovers: 7, Rebounds: 8}
fmt.Println(hadAGoodGame(s))
}

func hadAGoodGame(stats Stats) (bool, error) {
if stats.Assists < 0 || stats.Points < 0 || stats.Rebounds < 0 || stats.Minutes < 0 || stats.Turnovers < 0 {
return false, fmt.Errorf("stat lines cannot be negative")
}
if stats.Name == "" {
return false, fmt.Errorf("missing player name")
}
if stats.Assists >= (stats.Turnovers * 2) {
return true, nil
}
if stats.Assists >= 10 && stats.Rebounds >= 10 && stats.Points >= 10 {
return true, nil
} else if stats.Points < 10 && stats.Assists < 10 && stats.Minutes > 25.0 {
return false, nil
}
return false, nil
}

Obviously we have many cases to test, let’s start with two:

package main

import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)

func TestHadAGoodGame(t *testing.T) {
t.Run("sad path: invalid stats", func(t *testing.T) {
s := Stats{Name: "Sam Cassell",
Minutes: 34.1,
Points: -19,
Assists: 8,
Turnovers: -4,
Rebounds: 11,
}
_, err := hadAGoodGame(s)
require.NotNil(t, err)
})

t.Run("happy path: good game", func(t *testing.T) {
s := Stats{Name: "Dejounte Murray",
Minutes: 34.1,
Points: 19,
Assists: 8,
Turnovers: 4,
Rebounds: 11,
}
isAGoodGame, err := hadAGoodGame(s)
require.Nil(t, err)
assert.True(t, isAGoodGame)
})
}

You see where it’s going: what happens when we want to test 4 cases? 5? do we start repeating all over again and again? No.

There is a cool alternative called Table Driven Testing:
Table Driven Tests allow you to create compact, readable tests and make it easier to cover all cases, due to the fact that all the test data is organized and concise.

package main

import (
"github.com/stretchr/testify/assert"
"testing"
)

func TestHadAGoodGame(t *testing.T) {
tests := []struct {
name string
stats Stats
goodGame bool
wantErr string
}{
{"sad path: invalid stats", Stats{Name: "Sam Cassell",
Minutes: 34.1,
Points: -19,
Assists: 8,
Turnovers: -4,
Rebounds: 11,
}, false, "stat lines cannot be negative",
},
{"happy path: good game", Stats{Name: "Dejounte Murray",
Minutes: 34.1,
Points: 19,
Assists: 8,
Turnovers: 4,
Rebounds: 11,
}, true, ""},
}
for _, tt := range tests {
isAGoodGame, err := hadAGoodGame(tt.stats)
if tt.wantErr != "" {
assert.Contains(t, err.Error(), tt.wantErr)
} else {
assert.Equal(t, tt.goodGame, isAGoodGame)
}
}
}

Test Suite

Sometimes your test may require you to initialize a context, declare a variable or do any sort of setup prior to running the tests or tear down when finished.

Using a test suite is a convenient way to achieve this.
Golang has a nice package called suite that helps you get there.
(As you have probably noticed, the entire testify repository contains tools that will help you create quality tests.)

After incorporating testify’s suite, the test from the previous section will look like this:

package main

import (
"fmt"
"github.com/stretchr/testify/suite"
"testing"
)

type GameTestSuite struct {
suite.Suite
}

func (suite *GameTestSuite) BeforeTest(_, _ string) {
// execute code before test starts
fmt.Println("BEFORE")
}

func (suite *GameTestSuite) AfterTest(_, _ string) {
// execute code after test finishes
fmt.Println("AFTER")
}

func (suite *GameTestSuite) SetupSuite() {
// create relevant resources
fmt.Println("SETUP")
}

func TestGameTestSuite(t *testing.T) {
suite.Run(t, new(GameTestSuite))
}

func (suite *GameTestSuite) TestHadAGoodGame() {
tests := []struct {
name string
stats Stats
goodGame bool
wantErr string
}{
{"sad path: invalid stats", Stats{Name: "Sam Cassell",
Minutes: 34.1,
Points: -19,
Assists: 8,
Turnovers: -4,
Rebounds: 11,
}, false, "stat lines cannot be negative",
},
{"happy path: good game", Stats{Name: "Dejounte Murray",
Minutes: 34.1,
Points: 19,
Assists: 8,
Turnovers: 4,
Rebounds: 11,
}, true, ""},
}
for _, tt := range tests {
suite.T().Run("test hadAGoodGame(): "+tt.name, func(t *testing.T) {
isAGoodGame, err := hadAGoodGame(tt.stats)
if tt.wantErr != "" {
suite.Require().Contains(err.Error(), tt.wantErr)
} else {
suite.Require().Equal(tt.goodGame, isAGoodGame)
}
})
}
}

Use Interfaces And Avoid file I/O

Now we have a function that reads player’s data from a file and prints it:

package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
)

type Player struct {
Name string `json:"name"`
Age int `json:"Age"`
}

func main() {
processData("data.txt")
}

func processData(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
return err
}
var players []Player

err = json.Unmarshal(data, &players)
if err != nil {
return err
}

for _, p := range players {
fmt.Println("Player name: ", p.Name)
}
return nil
}

What’s wrong with it?

  1. Not easily testable, because you need to make sure that the data.txt file exists and contains data before running the tests
  2. What if there is a large amount of data and the server that performs the tests has some lower grade hardware?
  3. You also don’t want to run into race conditions where concurrent tests use/modify the same file

You want your tests to be fast, independent, isolated, consistent and not flaky.

A better approach would be this:

package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"io"
)

type Player struct {
Name string `json:"name"`
Age int `json:"age"`
}

func main() {
processData("data.txt")
}

func processData(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
return unmarshalAndPrint(f)
}

func unmarshalAndPrint(f io.Reader) error {
data, err := ioutil.ReadAll(f)
if err != nil {
return err
}
var players []Player

err = json.Unmarshal(data, &players)
if err != nil {
return err
}

for _, p := range players {
fmt.Println("Player name: ", p.Name)
}
return nil
}

Test file:

package main

import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)

func TestUnmarshalAndPrint(t *testing.T) {
t.Run("testing unmarshalAndPrint()", func(t *testing.T) {
err := unmarshalAndPrint(strings.NewReader(`[{"name": "Dubi Gal", "age": 900}]`))
assert.Nil(t, err)
})
}

Now for testing, instead of preparing data and opening a file, we just pass a literal JSON string to strings.NewReader

However, if for some reason you still have to interact with a filesystem, consider using the following resources:
AFERO: A filesystem abstraction system for Go
fstest — testing implementations and users of file systems
TempFile, TempDir — create a temporary file/directory

Use httptest

Similar to the above, but this one deserves a section of it’s own.

Assume you have a function that receives an NBA player’s data, validates it and sends over an HTTP request for saving or further processing:

package main

import (
"bytes"
"fmt"
"net/http"
"encoding/json"
)

func main() {
playerInfo := PlayerInfo{Name: "White Mambda", Team: "San Antonio Spurs", Position: "Forward"}
err := savePlayerInfo(playerInfo, "http://players.nba.com")
if err != nil {
panic(err)
}
}

type PlayerInfo struct {
Name string
Team string
Position string
}

func savePlayerInfo(playerInfo PlayerInfo, url string) error {
if playerInfo.Name == "" || playerInfo.Position == "" || playerInfo.Team == "" {
return fmt.Errorf("missing data")
}
b, err := json.Marshal(playerInfo)
if err != nil {
return err
}
body := bytes.NewBuffer(b)
req, err := http.NewRequest("POST", url, body)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
_, err = client.Do(req)
return err
}

How would you test this function?

Well, you have probably found yourself testing a function that makes an HTTP request(s). Usually, the request itself is not the part that you want to test, but other things like how the input/output is being handled/manipulated..
Also, you don’t want your test to depend on a 3rd party that may throttle or have a rate limit that may make your tests flaky or affect your test results.

The httptest package provides utilities for HTTP testing and will easily handle HTTP requests for your tests.

Using httptest, this is how your test would look:

package main

import (
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"testing"
)

func TestSavePlayerInfo(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", "application/json")
res.WriteHeader(http.StatusOK)
_, err := res.Write([]byte(`{"message": "success"`))
if err != nil {
panic("cannot return http response")
}
}))
defer testServer.Close()

t.Run("sad path: invalid stats", func(t *testing.T) {
s := PlayerInfo{Name: "Denis Rodman", Team: "Chicago Bulls", Position: "Forward"}
err := savePlayerInfo(s, testServer.URL)
require.Nil(t, err)
})
}

You can control the response, headers, status code and basically everything you need.

Benchmark your code

Benchmarks are integrated into Golang’s testing package and can help you speed up your code execution and avoid costly performance penalties.

Ever wondered what one is the most efficient way to to unmarshal JSON?

package main

import (
"encoding/json"
"io"
"io/ioutil"
)
/*
func main() {
_, err := jsonDecoder(strings.NewReader(`[{"name": "Dubi Gal", "age": 900}]`))
if err != nil {
fmt.Println(err)
}

_, err = readAll(strings.NewReader(`[{"name": "Dubi Gal", "age": 900}]`))
if err != nil {
fmt.Println(err)
}
}
*/

type Player struct {
Name string `json:"name"`
Age int `json:"age"`
}

func jsonDecoder(r io.Reader) ([]Player, error) {
var players []Player
return players, json.NewDecoder(r).Decode(&players)
}

func readAll(r io.Reader) ([]Player, error) {
var players []Player
bytez, err := ioutil.ReadAll(r)
if err != nil {
return players, err
}

return players, json.Unmarshal(bytez, &players)
}

Let’s go and benchmark! (a larger data set will obviously yield more accurate results)
Test file:

package main

import (
"strings"
"testing"
)

var players = `[{"name": "Dubi Gal", "age": 900}, {"name": "jojo", "age": 51}]`

func BenchmarkJsonDecoder(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
jsonDecoder(strings.NewReader(players))
}
}

func BenchmarkReadAll(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
readAll(strings.NewReader(players))
}
}

Results:

$ go test -bench=.goos: darwin
goarch: amd64
pkg: medium/aaa2
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkJsonDecoder-12 600022 1984 ns/op 1128 B/op 15 allocs/op
BenchmarkReadAll-12 620658 1820 ns/op 928 B/op 13 allocs/op
PASS
ok medium/aaa2 3.733s

In this benchmark we find that the io.ReadAll method performs slightly better. This is due to the object being in memory already.
When streaming and/or dealing with large amounts of data (hundreds of megabytes and more), the JSON Decoder will probably be a better choice. But now that we know how to benchmark, you can benchmark and experiment on your own!

Backend Engineer

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

My best AWS project so far — memories from the 2020 BINGO project

Dependency Inversion vs. Dependency Injection

Its the time for Quiz⁉️

Implementing an Authentication Flow Part 1: Initial Design

What Languages are Used for Back End Development?

Understanding The SOLID Principles….

50 Shades of Programming: What if Christian Grey were a programmer?

Python With Open Statement: A Simple Guide — Codefather

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Reshef Sharvit

Reshef Sharvit

Backend Engineer

More from Medium

The Go Programming Language

OO in Go(Go)

Inheritance in Go

Detailed logging with Golang