DEV Community

loading...

Table Driven Unit Tests in Go

boncheff
Curious Go engineer working for a badass fintech!
・4 min read

Whether you have been writing code for some years, or just getting started in software engineering, table driven tests have something to offer to you.
In this article I will introduce you to table driven tests, and show you a few ways you can incorporate them in your codebase.

Let's get started!


Testing your code, in particular with unit tests, can save you a lot of time!

As the popular expression goes, "6 hours of debugging can save you 5 minutes of reading the documentation". We have all been there! But would we really need to spend 6 hours debugging our code if we had written good tests in the first place? Probably, not. There are plenty of articles on how to write good unit tests - this article is not one of them - so now that we got this out of the way, let me take you on a little journey.


Imagine you work in a bank, and are writing the code to perform a transfer of funds from one bank account to another (i.e. checking account to savings account). For the sake of simplicity, we can only use USD as a currency:

I want to transfer 150.99 USD from my checking account to my savings account.

type transferRequest struct {
     amount               float64
     currency             string 
     originAccountID      string 
     destinationAccountID string
}

func validateTransferRequest(request transferRequest) error {
       if request.amount <= 0 {
              return errors.New("invalid amount")
       }

       if request.currency != "USD" {
              return errors.New("invalid currency: must be USD")
       }

       if request.originAccountID == "" {
              return errors.New("empty origin account ID")
       }

       if request.destinationAccountID == "" {
              return errors.New("empty destination account ID")
       }

       return nil // request is valid so we return nil
}
Enter fullscreen mode Exit fullscreen mode

How do you go about testing this function? I can think of a few ways:

  • Approach 1: if you do not mind code repetition, you can write a test case to validate each request field
  • Approach 2: if you are in a rush, you can write an OK table test, removing some of the repetition from the first approach
  • Approach 3: if you are a perfectionist, you can write a table test on steroids which makes your test code more readable and extensible

Let's write some code for each approach and see how it looks.

Approach 1 (code duplication):

import (
       "errors"
       "testing"

       "github.com/stretchr/testify/assert"
       "github.com/stretchr/testify/require"
)
func TestValidator(t *testing.T) {
       t.Run("error due to invalid amount", func(t *testing.T) {
              transferRequest := transferRequest{
                     amount:               0,
                     currency:             "USD",
                     originAccountID:      "checking",
                     destinationAccountID: "savings",
              }

              err := validateTransferRequest(transferRequest)
              require.Error(t, err)
              assert.Equal(t, "invalid amount", err.Error())
       })

       t.Run("error due to invalid currency", func(t *testing.T) {
              transferRequest := transferRequest{
                     amount:               150.99,
                     currency:             "INR",
                     originAccountID:      "checking",
                     destinationAccountID: "savings",
              }

              err := validateTransferRequest(transferRequest)
              require.Error(t, err)
              assert.Equal(t, "invalid currency: must be USD", err.Error())
       })

       t.Run("error due to an empty origin account ID", func(t *testing.T) {...}

       t.Run("error due to an empty destination count ID", func(t *testing.T) {...}
}
Enter fullscreen mode Exit fullscreen mode

You get the idea - the test cases read nicely but there is a lot of repetition, and although readable, it can be quite overwhelming especially if your test file has hundreds or even thousands of lines of code!

IDE output for Approach 1<br>

Approach 2 (table driven tests):

import (
       "errors"
       "testing"

       "github.com/stretchr/testify/assert"
       "github.com/stretchr/testify/require"
)
func TestValidatorShouldError(t *testing.T) {
       type errorTestCases struct {
              description   string
              input         transferRequest
              expectedError string
       }

       for _, scenario := range []errorTestCases{
              {
                     description: "invalid amount",
                     input: transferRequest{
                            amount:               0,
                            currency:             "USD",
                            originAccountID:      "checking",
                            destinationAccountID: "savings",
                     },
                     expectedError: "invalid amount",
              },
              {
                     description: "invalid currency",
                     input: transferRequest{
                            amount:               150.99,
                            currency:             "INR",
                            originAccountID:      "checking",
                            destinationAccountID: "savings",
                     },
                     expectedError: "invalid currency: must be USD",
              },
              {
                     description: "invalid origin account ID",
                     input: transferRequest{
                            amount:               150.99,
                            currency:             "USD",
                            originAccountID:      "",
                            destinationAccountID: "savings",
                     },
                     expectedError: "empty origin account ID",
              },
              {
                     description: "invalid destination account ID",
                     input: transferRequest{
                            amount:               150.99,
                            currency:             "USD",
                            originAccountID:      "checking",
                            destinationAccountID: "",
                     },
                     expectedError: "empty destination account ID",
              },
       } {
              t.Run(scenario.description, func(t *testing.T) {
                     err := validateTransferRequest(scenario.input)
                     require.Error(t, err)
                     assert.Equal(t, scenario.expectedError, err.Error())
              })
       }
}
Enter fullscreen mode Exit fullscreen mode

This is already looking a lot better! We removed some of the boilerplate and still ended up with a readable and maintainable test file!

IDE output for Approach 2

Approach 3 (table driven tests on steroids):

import (
       "errors"
       "testing"

       "github.com/stretchr/testify/assert"
       "github.com/stretchr/testify/require"
)
func TestValidatorShouldError(t *testing.T) {
       for scenario, fn := range map[string]func(t *testing.T){
              "invalid amount":                 testInvalidAmount,
              "invalid currency":               testInvalidCurrency,
              "invalid origin account ID":      testInvalidOriginAccountID,
              "invalid destination account ID": testInvalidDestinationAccountID,
       } {
              t.Run(scenario, func(t *testing.T) {
                     fn(t)
              })
       }
}

func testInvalidAmount(t *testing.T) {
       transferRequest := transferRequest{
              amount:               0,
              currency:             "USD",
              originAccountID:      "checking",
              destinationAccountID: "savings",
       }

       err := validateTransferRequest(transferRequest)
       require.Error(t, err)
       assert.Equal(t, "invalid amount", err.Error())
}

func testInvalidCurrency(t *testing.T) {
       transferRequest := transferRequest{
              amount:               150.90,
              currency:             "INR",
              originAccountID:      "checking",
              destinationAccountID: "savings",
       }

       err := validateTransferRequest(transferRequest)
       require.Error(t, err)
       assert.Equal(t, "invalid currency: must be USD", err.Error())
}

func testInvalidOriginAccountID(t *testing.T) {...}

func testInvalidDestinationAccountID(t *testing.T) {...}
Enter fullscreen mode Exit fullscreen mode

IDE output for Approach 3

Whoa! Take a moment to let it sink in before you start cursing me!

It might look like all we have done is moved the duplication further down the page, but in reality this approach is a lot more powerful than the previous two.

First, it allows you to quickly scan through the test case definitions and see what we are testing, without getting burdened with the how.

Approach 3

Second, if you have more complicated business logic with a lot of edge cases for each test case (i.e. you only allow transfers where the currency of the origin and destination accounts match, or there is only a specific list of allowed currencies) you can abstract this in the test case function without burdering yourself with how exactly it is being tested, and only dig into each function if required.


So here you have it folks - here are 3 of my favourite ways of writing table driven tests in Go.

Please let me know if you follow any of these approaches yourself, or if you happen to write table tests differently, please let me know so I can learn a different approach!

I hope that you have learned something new or that you simply enjoyed reading it!

Discussion (0)