Video:

Table Driven Testing in Go

December 6, 2020

Course Instructor: Elliot Forbes

Hey Gophers! My name is Elliot and I'm the creator of TutorialEdge and I've been working with Go systems for roughly 5 years now.

Twitter: @Elliot_f

In this tutorial, we are going to be covering table-driven testing in Go. We’ll be incorporating what we learned in the previous video about sub-tests and coupling them up with this concept of table-driven testing in order to create a truly powerful method of testing the functionality of our code.

This approach will allow us to start truly testing the code that we’ve written and give us the confidence that it does work as expected. With just a couple modifications to our calculator_test package, we’ll be able to provide a truly comprehensive coverage with our tests and ensure that our code works as intended.

Table Driven Tests

Let’s have a look at the test code we have already which leverages sub-tests.

package calculator_test

import "testing"

type TestCase struct {
    value int
    expected bool
    actual bool
}

func TestCalculateIsArmstrong(t *testing.T) {
    t.Run("should return true for 371", func(t *testing.T) {
         testCase := TestCase{
            value: 371,
            expected: true,
        }

        testCase.Actual = CalculateIsArmstrong(testCase.value)
        if testCase.Actual != testCase.expected {
            t.Fail("CalculateIsArmstrong returned an unexpected result")
        }
    })

    t.Run("should return true for first armstrong number", func(t *testing.T) {
        testCase := TestCase{
            value: 1,
            expected: true,
        }

        testCase.Actual = CalculateIsArmstrong(testCase.value)
        if testCase.Actual != testCase.expected {
            t.Fail("CalculateIsArmstrong returned an unexpected result")
        }
    })

    t.Run("should return false for non-armstrong number", func(t *testing.T){
        testCase := TestCase{
            value: 15,
            expected: false,
        }

        testCase.Actual = CalculateIsArmstrong(testCase.value)
        if testCase.Actual != testCase.expected {
            t.Fail("CalculateIsArmstrong returned an unexpected result")
        }
    })
}

In this example, we’ve manually picked some example tests cases which help us validate that our Go code works as expected, however, for 3 test cases, we have had to define 3 distinct test functions.

However, using test-driven tests we can improve this implementation of our tests and ensure a wider range of inputs and results are covered:

package calculator_test

import (
	"testing"

	"github.com/TutorialEdge/go-testing-bible/calculator"
)

type TestCase struct {
	value    int
	expected bool
	actual   bool
}

func TestCalculateIsArmstrong(t *testing.T) {
	t.Run("test for all 3 digit armstrong numbers", func(t *testing.T) {
		tests := []TestCase{
			TestCase{value: 153, expected: true},
			TestCase{value: 370, expected: true},
			TestCase{value: 371, expected: true},
			TestCase{value: 407, expected: true},
		}

		for _, test := range tests {
			t.Run("test", func(t *testing.T) {
				actual := calculator.CalculateIsArmstrong(test.value)
				if test.expected != actual {
					t.Fail()
				}
			})
		}
	})

}

With a bit of refactoring we’ve been able to massively improve the effectiveness of the tests whilst also reduced the amount of code that we have to maintain going forward.

This test pattern has been widely adopted by the Go community due to the power it gives developers to quickly and easily test a wide variety of cases in a succinct but incredibly readable format.

Combining Concepts

In the above code, we’ve been able to combine 2 fundamental concepts. We use sub-tests to group up the tests that we expect to pass and the tests we expect to fail, and then we’ve defined an array of TestCase which we then range through and run another sub-test using the values.

This approach can be applied to an incredibly wide range of different applications. Say for instance you were testing a REST API and you wanted to test every possible response for a given endpoint. You could, in theory, take this approach and map out a comprehensive suite of TestCases which include a large number of different request objects coupled with their expected response objects.

Modifications

Now, there is one way we could take this a step further and make it a little more verbose. We could add another field to the TestCase struct which defines a Name for our tests. We can then pass this into the calls to t.Run(test.Name) which then grants us further flexibility when it comes to selectively running tests using the -run regex.

package calculator_test

import (
	"testing"

	"github.com/TutorialEdge/go-testing-bible/calculator"
)

type TestCase struct {
	name     string
	value    int
	expected bool
	actual   bool
}

func TestCalculateIsArmstrong(t *testing.T) {
	t.Run("test for all 3 digit armstrong numbers", func(t *testing.T) {
		tests := []TestCase{
			TestCase{name: "Testing value for: 153", value: 153, expected: true},
			TestCase{name: "Testing value for: 370", value: 370, expected: true},
			TestCase{name: "Testing value for: 371", value: 371, expected: true},
			TestCase{name: "Testing value for: 407", value: 407, expected: true},
		}

		for _, test := range tests {
			t.Run(test.name, func(t *testing.T) {
				actual := calculator.CalculateIsArmstrong(test.value)
				if test.expected != actual {
					t.Fail()
				}
			})
		}
	})

}

func TestNegativeCalculateIsArmstrong(t *testing.T) {
	t.Run("should fail for case 350", func(t *testing.T) {
		testCase := TestCase{
			value:    350,
			expected: false,
		}

		testCase.actual = calculator.CalculateIsArmstrong(testCase.value)
		if testCase.actual != testCase.expected {
			t.Fail()
		}
	})

	t.Run("should fail for case 300", func(t *testing.T) {
		testCase := TestCase{
			value:    300,
			expected: false,
		}

		testCase.actual = calculator.CalculateIsArmstrong(testCase.value)
		if testCase.actual != testCase.expected {
			t.Fail()
		}
	})
}

Now try run this:

$ go test ./... -v
=== RUN   TestCalculateIsArmstrong
=== RUN   TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers
=== RUN   TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_153
=== RUN   TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_370
=== RUN   TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_371
=== RUN   TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_407
--- PASS: TestCalculateIsArmstrong (0.00s)
    --- PASS: TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers (0.00s)
        --- PASS: TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_153 (0.00s)
        --- PASS: TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_370 (0.00s)
        --- PASS: TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_371 (0.00s)
        --- PASS: TestCalculateIsArmstrong/test_for_all_3_digit_armstrong_numbers/Testing_value_for:_407 (0.00s)
=== RUN   TestNegativeCalculateIsArmstrong
=== RUN   TestNegativeCalculateIsArmstrong/should_fail_for_case_350
=== RUN   TestNegativeCalculateIsArmstrong/should_fail_for_case_300
--- PASS: TestNegativeCalculateIsArmstrong (0.00s)
    --- PASS: TestNegativeCalculateIsArmstrong/should_fail_for_case_350 (0.00s)
    --- PASS: TestNegativeCalculateIsArmstrong/should_fail_for_case_300 (0.00s)
PASS
ok      github.com/TutorialEdge/go-testing-bible/calculator     0.280s

Conclusion

Hopefully this tutorial has given you an appreciation for the power that table-driven testing has when it comes to developing incredibly powerful tests for your Go applications. We’ve seen just a simple implementation of this already, but in future videos we’ll be looking at how we can use this approach to test applications such as our REST APIs.

In the next tutorial, we’ll be looking at how you can check what branches of your code have been covered by tests and calculate the overall code coverage of a project.