DEV Community

Dixon Osure
Dixon Osure

Posted on

Mastering Go Testing: From Basics to Subtests in 3 Minutes

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
  • 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.Errorf to fail tests.
  • Table-Driven: Use []struct to organize data.
  • Subtests: Use t.Run for better reporting and control.

Happy testing!

Top comments (2)

Collapse
 
theminimalcreator profile image
Guilherme Zaia

"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!"

Collapse
 
d-osure profile image
Dixon Osure

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