advanced go table driven tests

Table Driven Tests (TDTs) provides a Don’t Repeat Yourself (DRY) approach of separating test variables from the actual reusable test logic. The separation of two concerns nourishes the clarity of what we are trying to achieve with our tests. A combination of subtests and testify provides all the tools you would ever need to write advanced tests in Go.

There are multiple great articles out there that talk about TDTs, to give kudos, I have to mention Dave Cheney’s “Writing table driven tests in Go” article which dates back to 2013, nearly seven years ago before this post, which probably is the inspiration of TDTs in Go for most of the developers that I know. I also want to thank fellow gophers who suggested me to give this testing approach a try, without you, testing in Go wouldn’t be as pleasant as it’s now.

subtests

In TDTs, we loop over test cases. The more advanced approach is to use subtests as this provides means of:

  • running tests cases in parallel
  • executing a specific test case in isolation from other cases

in parallel

Let’s say we have two tests TestFoo and TestBar, if we would want to execute both tests in parallel, we would use Parallel in each of these tests. However, it’s also possible to run each subtest test case in parallel by calling Parallel on each iteration, and this at first might be quite confusing.

What complicates things a bit further is that if we decided to run tests in parallel, we need to capture the range variable to ensure that we aren’t sharing the test case variable between iterations.

package main

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestFoo(t *testing.T) {
	t.Parallel() // marks TestFoo to be run in parallel with TestBar
	for name, tc := range map[string]struct {
		description   string
		myString      string
		expectToBeFoo bool
	}{
		"IsFoo": {
			description:   "My string is foo.",
			myString:      "foo",
			expectToBeFoo: true,
		},
		"IsNotFoo": {
			description:   "My string isn't foo",
			myString:      "bar",
			expectToBeFoo: false,
		},
	} {
		tc := tc // capturing range variable
		t.Run(name, func(t *testing.T) {
			t.Parallel() // run each test case in parallel
			t.Log(tc.description)

			if tc.expectToBeFoo {
				assert.Equal(t, tc.myString, foo())
			} else {
				assert.NotEqual(t, tc.myString, foo())
			}
		})
	}
}

func TestBar(t *testing.T) {
	t.Parallel() // marks TestBar to be run in parallel with TestFoo
	for name, tc := range map[string]struct {
		description   string
		myString      string
		expectToBeBar bool
	}{
		"IsFoo": {
			description:   "My string is bar.",
			myString:      "bar",
			expectToBeBar: true,
		},
		"IsNotFoo": {
			description:   "My string isn't bar",
			myString:      "foo",
			expectToBeBar: false,
		},
	} {
		tc := tc // capturing range variable
		t.Run(name, func(t *testing.T) {
			t.Parallel() // run each test case in parallel
			t.Log(tc.description)

			if tc.expectToBeBar {
				assert.Equal(t, tc.myString, bar())
			} else {
				assert.NotEqual(t, tc.myString, bar())
			}
		})
	}
}

func foo() string {
	time.Sleep(5 * time.Second)
	return "foo"
}

func bar() string {
	time.Sleep(5 * time.Second)
	return "bar"
}

Practice makes perfect:

  • Comment out t.Parallel() function calls and observe the time difference of test execution. With all t.Parallel() function calls commented out, tests take 20 seconds to complete compared to only 5 seconds when all tests run in parallel.
  • To see why capturing the range variable matters, comment out the test case from the TestFoo test function and set expectToBeFoo value to true. Run the test and observe the test results.

testify assertion functions

Testify provides assertion functions which makes assertions dynamical per each test case, which in return increases the clarity of tests. Currently Testify provides 4 assertion functions used to:

  • ComparisonAssertionFunc - compare two values
  • ValueAssertionFunc - validate a single value
  • BoolAssertionFunc - validate boolean value
  • ErrorAssertionFunc - validate error value

We can refactor the previous TestFoo test function example to make use of ComparisonAssertionFunc with assert.Equal and assert.NotEqual.

func TestFoo(t *testing.T) {
	t.Parallel() // marks TestFoo to be run in parallel with TestBar
	for name, tc := range map[string]struct {
		description   string
		myString      string
		assertEqual   assert.ComparisonAssertionFunc // provides means of dynamically asserting two values per each test case
	}{
		"IsFoo": {
			description: "My string is foo.",
			myString:    "foo",
			assertEqual: assert.Equal,
		},
		"IsNotFoo": {
			description: "My string isn't foo",
			myString:    "bar",
			assertEqual: assert.NotEqual,
		},
	} {
		tc := tc // capturing range variable
		t.Run(name, func(t *testing.T) {
			t.Parallel() // run each test case in parallel
			t.Log(tc.description)

			tc.assertEqual(t, tc.myString, foo())
		})
	}
}

require no errors

When setting up tests, sometimes developers tend to assign errors to the blank identifier, as probably there is no point of checking errors, right? Well not really, we should always check for errors, even when we think no errors might occur, you never really know when this might come back and bite you. For this reason, I use testify require package to terminate my tests early if I got an unexpected error while setting up my tests.