Lula Leus
Posted on August 6, 2022
Intro
Unit tests are an essential part of any code base, including the one at my workplace. When an engineer adds a piece of new logic into the codebase, they are expected to add unit tests that will cover, at least, the most common scenarios of the new logic usage. Perhaps with a couple of edge cases as a cherry on the top.
Testing different scenarios is important, but requires writing fairly repetitive tests. Let’s look at approaches Golang offers for this.
Testing packing of a doughnuts box
We’ll look at two different ways to write unit tests in Golang. We will use a playful example of packing a doughnuts box. The flavours of the doughnuts in the examples, are all inspired by Crosstown doughnuts. Let’s look at the code, for which we’ll write tests.
Implementation of a doughnut box
package doughnuts_box
import (
"fmt"
)
type doughnutsBox struct {
capacity int
doughnuts []string
}
var knownDoughnutTypes = map[string]bool{
"Matcha Tea": true,
"Lime & Coconut (ve)": true,
"Home Made Raspberry Jam": true,
"Cinnamon Scroll (ve)": true,
"Sri Lankan Cinnamon Sugar": true,
}
func newDoughnutsBox(capacity int) *doughnutsBox {
return &doughnutsBox{
capacity: capacity,
doughnuts: make([]string, 0),
}
}
func (b *doughnutsBox) pack(doughnuts []string) (int, error) {
unrecognizedItems := make([]string, 0)
var err error
if len(doughnuts) > b.capacity {
return 0, fmt.Errorf("failed to put %d doughnuts in the box, it's only has %d doughnuts capacity", len(doughnuts), b.capacity)
}
for _, doughnut := range doughnuts {
if _, found := knownDoughnutTypes[doughnut]; found {
b.doughnuts = append(b.doughnuts, doughnut)
continue
}
unrecognizedItems = append(unrecognizedItems, doughnut)
}
if len(unrecognizedItems) > 0 {
err = fmt.Errorf("the following items cannot be placed into the box: %v", unrecognizedItems)
}
return len(b.doughnuts), err
}
Now, after we familiarised ourself with the doughnut box implementation, let’s discuss how we can tests it.
Testing packing the doughnuts box with table-driven tests
Table-driven tests are the common way to write unit tests in Golang. You’ll find this style everywhere.
Table tests offer a condensed way to write test scenarios while keeping code repetition to a minimum. The syntax density is coming with the drawback of poor readability. The tests may feel elegant to write, but they are often hard to read and reason about.
The table-tests are shining in situations, where the intention is to test a large number of scenarios, with clear
input x produces output y expectations.
For our doughnut box example, we wrote a set of scenarios in the shape of anonymous struct testCases
. Then we run all of them as subtests with t.Run
method, inside a familiar for loop.
Implementation of doughnuts box table-driven tests
package doughnuts_box
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPackDoughnutsBoxTableTests(t *testing.T) {
// Anonymous struct of test cases
tests := []struct {
name string
boxCapacity int
errorExpected bool
errorMessage string
items []string
expectedNumOfDoughnutsInTheBox int
}{
{
name: "Filling the box with tasty doughnuts",
boxCapacity: 4,
errorExpected: false,
errorMessage: "",
items: []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)"},
expectedNumOfDoughnutsInTheBox: 4,
},
{
name: "Attempt to fill the box with too many doughnuts",
boxCapacity: 4,
errorExpected: true,
errorMessage: "failed to put 5 doughnuts in the box, it's only has 4 doughnuts capacity",
items: []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)", "Lime & Coconut (ve)"},
expectedNumOfDoughnutsInTheBox: 0,
},
{
name: "Attempt to put a giant chocolate cookie into the box",
boxCapacity: 2,
errorExpected: true,
errorMessage: "the following items cannot be placed into the box: [Giant Chocolate Cookie]",
items: []string{"Sri Lankan Cinnamon Sugar", "Giant Chocolate Cookie"},
expectedNumOfDoughnutsInTheBox: 1,
},
}
for _, tc := range tests {
// each test case from table above run as a subtest
t.Run(tc.name, func(t *testing.T) {
// Arrange
box := newDoughnutsBox(tc.boxCapacity)
// Act
numOfDoughnutsInTheBox, err := box.pack(tc.items)
// Assert
if tc.errorExpected {
require.Error(t, err)
assert.Equal(t, tc.errorMessage, err.Error())
}
assert.Equal(t, tc.expectedNumOfDoughnutsInTheBox, numOfDoughnutsInTheBox)
})
}
}
We looked at the "happy path" test case and two test cases for errors we expect to be common - trying to put too many doughnuts into a box and an attempt to put something that isn't doughnut into the box.
Next, we'll look at how we can re-write the same tests in different style.
Testing packing the doughnuts box with individual subtests
Writing tests scenarios as an individual subtests is less common in Golang. This fact is regretful, in my opinion. But still there are enough of examples of this syntax.
Individual subtests syntax, unlike table tests, favours readability overall “code dryness”. The tests may look more repetitive, but each individual subtest represents an individual “story”. As such, it is easier to follow behaviour-driven testing principles with individual subtests than with table-driven tests.
In our small example, all arrangements are made within the individual subtests. In more complex examples, the state shared between subtests can be initialised in the body of the parent test. And repetitive setup/teardown can be extracted into helper functions.
Implementation of Donuts box test as individual subtests
package doughnuts_box
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPackDoughnutsBoxSubtests(t *testing.T) {
t.Run("It fills the box with tasty doughnuts", func(t *testing.T) {
// Arrange
items := []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)"}
box := newDoughnutsBox(4)
// Act
numOfDoughnutsInTheBox, err := box.pack(items)
// Assert
require.NoError(t, err)
assert.Equal(t, numOfDoughnutsInTheBox, 4)
})
t.Run("It fails to fill the box with too many doughnuts", func(t *testing.T) {
// Arrange
items := []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)", "Lime & Coconut (ve)"}
box := newDoughnutsBox(4)
// Act
numOfDoughnutsInTheBox, err := box.pack(items)
// Assert
require.Error(t, err)
assert.Equal(t, "failed to put 5 doughnuts in the box, it's only has 4 doughnuts capacity", err.Error())
assert.Equal(t, 0, numOfDoughnutsInTheBox)
})
t.Run("It fails to put a giant chocolate cookie into the box", func(t *testing.T) {
// Arrange
items := []string{"Sri Lankan Cinnamon Sugar", "Giant Chocolate Cookie"}
box := newDoughnutsBox(4)
// Act
numOfDoughnutsInTheBox, err := box.pack(items)
// Assert
require.Error(t, err)
assert.Equal(t, "the following items cannot be placed into the box: [Giant Chocolate Cookie]", err.Error())
assert.Equal(t, 1, numOfDoughnutsInTheBox)
})
}
We wrote the same three test cases - happy path and two common error scenarios as individual sub-tests. In our example, they even took approximately the same number of code lines. 58 lines for individual subtests, comparing to 63 lines of table-driven tests example.
Conclusions
Now, when examples for two styles for unit tests are presented, it is time to decide which one to use and when.
As an engineer, you should exercise your better judgement on how to write unit tests and what syntax suits best. You can use the following little diagram to help you to decide, which syntax to use.
I hope the diagram helps. If you have a strong opinion for one syntax over another or have your own way of writing Golang unit tests, please share in the comments!
Posted on August 6, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.