Testing in Go is a breath of fresh air. There is no need to learn complicated frameworks or configure assertion libraries. The standard library is all you need.
In this 3-minute guide, we'll help you upgrade your testing skills from "hello world" to professional grade table-driven tests.
Level 1: The Basic Test
In Go, tests live in files ending with _test.go. A test is just a function that starts with Test and takes a *testing.T.
Here is a simple test for an Add function:
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
We don't use assertions like assert.Equal. We just use if statements and t.Errorf to report failures. Simple!
Level 2: Table-Driven Tests
What if we want to test Add(0, 0), Add(-1, 1), and Add(100, 200)? Copy-pasting the test above is tedious and error-prone.
This is where Table-Driven Tests come in. This is the idiomatic way to write tests in Go. We define our test cases in a slice of structs (the "table"), then simply loop over them.
func TestAdd(t *testing.T) {
tests := []struct {
a, b int
expected int
}{
{2, 3, 5},
{0, 0, 0},
{-1, 1, 0},
}
for _, tt := range tests {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
}
}
Now, adding a new test case is just adding one line to the slice. Clean, right?
Level 3: Subtests with t.Run
The table-driven approach is great, but what if one case fails? It can be difficult to identify which case failed out of a sea of log messages. What if we want to run just one particular case?
This is where Subtests come in. We'll use t.Run to define a separate test function for each case:
First, let's add a name field to our struct:
func TestAdd(t *testing.T) {
tests := []struct {
name string // New!
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"zeros", 0, 0, 0},
{"negative", -1, 1, 0},
}
for _, tt := range tests {
// t.Run creates a subtest
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
Why is this better?
- Better Output/Easier Debugging: The failed test has a clear name: TestAdd/positive_numbers
- Granular Control: You can run individual subtests from the command line.
go test -run TestAdd/zeros
-
Parallelism: And you can even run subtests in parallel with
t.Parallel()inside a loop!
Summary
We started with a simple function and ended up with a scalable, professional testing pattern with only a few lines of code.
-
Basics: Use
t.Errorfto fail tests. -
Table-Driven: Use
[]structto organize data. -
Subtests: Use
t.Runfor better reporting and control.
Happy testing!
Top comments (2)
"Great breakdown on Go tests! The emphasis on table-driven tests is spot on—it's a robust pattern that enhances maintainability. Have you thought about using
t.Parallel()for the table-driven tests? It can dramatically reduce the overall test run time, especially for larger test suites. Happy coding!"Thank you Zaia for your generous compliments. That's a great suggestion. I have used
t.Parallel()for table-driven testing and seen the benefits. I will make a follow up article on that and more advanced testing methods. Cheers