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:
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:
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:
The test is just regular Go code, which calls the
humanDate()
function and checks that the result matches what we expect.Your unit tests are contained in a normal Go function with the signature
func(*testing.T)
.To be a valid unit test, the name of this function must begin with the word
Test
. Typically this is then followed by the name of the function, method or type that you’re testing to help make it obvious at a glance what is being tested.You can use the
t.Errorf()
function to mark a test as failed and log a descriptive message about the failure. It’s important to note that callingt.Errorf()
doesn’t stop execution of your test — after you call it Go will continue executing any remaining test code as normal.
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:
If the input to
humanDate()
is the zero time, then it returns the empty string""
.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:
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:
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:
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:
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... }) }