Table-driven and individual subtests in Golang: which one to use

lulaleus

Lula Leus

Posted on August 6, 2022

Table-driven and individual subtests in Golang: which one to use

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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.

Diagram helper to choice between table-driven and individual sub-test

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!

💖 💪 🙅 🚩
lulaleus
Lula Leus

Posted on August 6, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related