Let's Go Testing › Unit testing and sub-tests
Previous · Contents · Next
Chapter 13.1.

Unit testing and sub-tests

In this chapter we’ll create a unit test to make sure that our humanDate() function (which we made back in the custom template functions chapter) is outputting time.Time values in the exact format that we want.

If you can’t remember, the humanDate() function looks like this:

File: cmd/web/templates.go
package main

...

func humanDate(t time.Time) string {
    return t.Format("02 Jan 2006 at 15:04")
}

...

The reason that I want to start by testing this is because it’s a simple function. We can explore the basic syntax and patterns for writing tests without getting too caught-up in the functionality that we’re testing.

Creating a unit test

Let’s jump straight in and create a unit test for this function.

In Go, it’s standard practice to write your tests in *_test.go files which live directly alongside the code that you’re testing. So, in this case, the first thing that we’re going to do is create a new cmd/web/templates_test.go file to hold the test:

$ touch cmd/web/templates_test.go

And then we can create a new unit test for the humanDate function like so:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"
)

func TestHumanDate(t *testing.T) {
    // Initialize a new time.Time object and pass it to the humanDate function.
    tm := time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC)
    hd := humanDate(tm)

    // Check that the output from the humanDate function is in the format we
    // expect. If it isn't what we expect, use the t.Errorf() function to
    // indicate that the test has failed and log the expected and actual
    // values.
    if hd != "17 Mar 2024 at 10:15" {
        t.Errorf("got %q; want %q", hd, "17 Mar 2024 at 10:15")
    }
}

This pattern is the basic one that you’ll use for nearly all tests that you write in Go. The important things to take away are:

Let’s try this out. Save the file, then use the go test command to run all the tests in our cmd/web package like so:

$ go test ./cmd/web
ok      snippetbox.alexedwards.net/cmd/web    0.005s

So, this is good stuff. The ok in this output indicates that all tests in the package (for now, only our TestHumanDate() test) passed without any problems.

If you want more detail, you can see exactly which tests are being run by using the -v flag to get the verbose output:

$ go test -v ./cmd/web
=== RUN   TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok      snippetbox.alexedwards.net/cmd/web    0.007s

Table-driven tests

Let’s now expand our TestHumanDate() function to cover some additional test cases. Specifically, we’re going to update it to also check that:

  1. If the input to humanDate() is the zero time, then it returns the empty string "".

  2. The output from the humanDate() function always uses the UTC time zone.

In Go, an idiomatic way to run multiple test cases is to use table-driven tests.

Essentially, the idea behind table-driven tests is to create a ‘table’ of test cases containing the inputs and expected outputs, and to then loop over these, running each test case in a sub-test. There are a few ways you could set this up, but a common approach is to define your test cases in an slice of anonymous structs.

I’ll demonstrate:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"
)

func TestHumanDate(t *testing.T) {
    // Create a slice of anonymous structs containing the test case name,
    // input to our humanDate() function (the tm field), and expected output
    // (the want field).
    tests := []struct {
        name string
        tm   time.Time
        want string
    }{
        {
            name: "UTC",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC),
            want: "17 Mar 2024 at 10:15",
        },
        {
            name: "Empty",
            tm:   time.Time{},
            want: "",
        },
        {
            name: "CET",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
            want: "17 Mar 2024 at 09:15",
        },
    }

    // Loop over the test cases.
    for _, tt := range tests {
        // Use the t.Run() function to run a sub-test for each test case. The
        // first parameter to this is the name of the test (which is used to
        // identify the sub-test in any log output) and the second parameter is
        // and anonymous function containing the actual test for each case.
        t.Run(tt.name, func(t *testing.T) {
            hd := humanDate(tt.tm)

            if hd != tt.want {
                t.Errorf("got %q; want %q", hd, tt.want)
            }
        })
    }
}

OK, let’s run this and see what happens:

$ go test -v ./cmd/web
=== RUN   TestHumanDate
=== RUN   TestHumanDate/UTC
=== RUN   TestHumanDate/Empty
    templates_test.go:44: got "01 Jan 0001 at 00:00"; want ""
=== RUN   TestHumanDate/CET
    templates_test.go:44: got "17 Mar 2024 at 10:15"; want "17 Mar 2024 at 09:15"
--- FAIL: TestHumanDate (0.00s)
    --- PASS: TestHumanDate/UTC (0.00s)
    --- FAIL: TestHumanDate/Empty (0.00s)
    --- FAIL: TestHumanDate/CET (0.00s)
FAIL
FAIL    snippetbox.alexedwards.net/cmd/web      0.003s
FAIL

So here we can see the individual output for each of our sub-tests. As you might have guessed, our first test case passed but the Empty and CET tests both failed. Notice how — for the failed test cases — we get the relevant failure message and filename and line number in the output?

Let’s head back to our humanDate() function and update it to fix these two problems:

File: cmd/web/templates.go
package main

...

func humanDate(t time.Time) string {
    // Return the empty string if time has the zero value.
    if t.IsZero() {
        return ""
    }

    // Convert the time to UTC before formatting it.
    return t.UTC().Format("02 Jan 2006 at 15:04")
}

...

And when you re-run the tests everything should now pass.

$ go test -v ./cmd/web
=== RUN   TestHumanDate
=== RUN   TestHumanDate/UTC
=== RUN   TestHumanDate/Empty
=== RUN   TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
    --- PASS: TestHumanDate/UTC (0.00s)
    --- PASS: TestHumanDate/Empty (0.00s)
    --- PASS: TestHumanDate/CET (0.00s)
PASS
ok      snippetbox.alexedwards.net/cmd/web      0.003s

Helpers for test assertions

As I mentioned briefly earlier in the book, over the next few chapters we’ll be writing a lot of test assertions that are a variation of this pattern:

if actualValue != expectedValue {
    t.Errorf("got %v; want %v", actualValue, expectedValue)
}

Let’s quickly abstract this code into a helper function.

If you’re following along, go ahead and create a new internal/assert package:

$ mkdir internal/assert
$ touch internal/assert/assert.go

And then add the following code:

File: internal/assert/assert.go
package assert

import (
    "testing"
)

func Equal[T comparable](t *testing.T, actual, expected T) {
    t.Helper()

    if actual != expected {
        t.Errorf("got: %v; want: %v", actual, expected)
    }
}

Notice how Equal() is a generic function? This means that we’ll be able to use it irrespective of what the type of the actual and expected values is. So long as both actual and expected have the same type and can be compared using the != operator (for example, they are both string values, or both int values) our test code should compile and work fine when we call Equal().

With that in place, we can simplify our TestHumanDate() test like so:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"

    "snippetbox.alexedwards.net/internal/assert" // New import
)

func TestHumanDate(t *testing.T) {
    tests := []struct {
        name string
        tm   time.Time
        want string
    }{
        {
            name: "UTC",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC),
            want: "17 Mar 2024 at 10:15",
        },
        {
            name: "Empty",
            tm:   time.Time{},
            want: "",
        },
        {
            name: "CET",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
            want: "17 Mar 2024 at 09:15",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            hd := humanDate(tt.tm)

            // Use the new assert.Equal() helper to compare the expected and 
            // actual values.
            assert.Equal(t, hd, tt.want)
        })
    }
}

Additional information

Sub-tests without a table of test cases

It’s important to point out that you don’t need to use sub-tests in conjunction with table-driven tests (like we have done so far in this chapter). It’s perfectly valid to execute sub-tests by calling t.Run() consecutively in your test functions, similar to this:

func TestExample(t *testing.T) {
    t.Run("Example sub-test 1", func(t *testing.T) {
        // Do a test.
    })

    t.Run("Example sub-test 2", func(t *testing.T) {
        // Do another test.
    })

    t.Run("Example sub-test 3", func(t *testing.T) {
        // And another...
    })
}