Skip to Content
Course content

176: Subtests with t.Run

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're a quality assurance inspector at a car factory. Your job is to "Verify the Electrical System." That sounds like one single task, but in reality, it's a collection of smaller checks: Do the headlights work? Do the blinkers flash? Does the radio turn on? If the blinkers are broken, you don't just throw your hands up and mark the entire car as "Electrical Failure" and walk away. You mark the blinkers as failed, but you still check the radio and the headlights to see exactly what's broken.

In Go, t.Run is how we do exactly that. It lets us take one big test function—the "Electrical System" check—and carve it into smaller, named subtests. This is a huge upgrade from just looping through a slice of test cases and calling t.Errorf, because it gives each case its own identity and its own lifecycle.

Stopping the "All-or-Nothing" Failure

I've seen a lot of developers write table-driven tests using a simple for loop. It works fine until you have twenty cases and the third one fails. Suddenly, the test output is a wall of text, and it's a pain to figure out exactly which input caused the crash. Worse, if you use t.Fatalf inside a loop, the first failure kills the entire test suite for that function. You lose visibility into whether the other nineteen cases would have passed.

When you wrap your test logic in t.Run, Go treats each subtest as its own entity. If one subtest fails, the others keep running. Plus, the output tells you exactly which subtest failed by printing the name you gave it.

The Table-Driven Pattern with Subtests

Let's look at a real scenario. Suppose you're writing a function that validates passwords. It needs to check for length, numbers, and special characters. Instead of writing TestPasswordLength, TestPasswordNumbers, and so on, we group them.

func TestValidatePassword(t *testing.T) {
    tests := []struct {
        name     string
        password string
        want     bool
    }{
        {"TooShort", "123!", false},
        {"NoNumber", "Password!", false},
        {"NoSpecial", "Password123", false},
        {"Valid", "StrongPass123!", true},
    }

    for _, tc := range tests {
        // We use t.Run to create a named subtest for each case
        t.Run(tc.name, func(t *testing.T) {
            got := ValidatePassword(tc.password)
            if got != tc.want {
                t.Errorf("ValidatePassword(%q) = %v; want %v", tc.password, got, tc.want)
            }
        })
    }
}

Notice how I'm passing tc.name as the first argument. If the "NoNumber" case fails, Go will report the failure as TestValidatePassword/NoNumber. It makes your logs infinitely more readable.

Picking and Choosing Your Tests

One of my favorite "hidden" benefits of t.Run is how it interacts with the command line. When you have a massive test suite, you don't always want to run everything. Because subtests have names, you can use the -run flag with a regular expression to execute only a specific subtest.

If you only want to debug the "NoNumber" case from the example above, you can run:

go test -v -run TestValidatePassword/NoNumber

This saves you a ton of time. You aren't waiting for the entire suite to finish just to see if your fix for one specific edge case worked. I use this constantly when I'm hunting down a tricky bug in a complex data pipeline.

A Quick Note on Closures

One thing to watch out for: in older versions of Go (before 1.22), using the loop variable tc inside the t.Run closure was a common trap. Because the loop variable was reused, all your subtests might have ended up running the final test case. In modern Go, this is handled for you, but if you're working on a legacy codebase, you might see people manually re-declaring the variable inside the loop (e.g., tc := tc). Just keep that in the back of your mind if you see some weird shadowing happening.




📋 Practical Task

Refactoring the Shipping Calculator Tests

You have been handed a CalculateShipping function that determines costs based on weight and destination. The current test suite is a single loop that is hard to debug. Your task is to refactor the existing test into a table-driven test using t.Run.

The Function to Test:

func CalculateShipping(weight float64, destination string) float64 {
    if destination == "Local" {
        return weight * 0.5
    }
    if destination == "International" {
        return weight * 2.0
    }
    return weight * 1.0 // Default/National
}

Your Requirements:

  • Create a TestCalculateShipping function.
  • Define a slice of anonymous structs containing: a name (string), weight (float64), destination (string), and want (float64).
  • Include at least four test cases: one for Local, one for International, one for National, and one for a zero-weight edge case.
  • Wrap the logic inside the loop with t.Run, using the name field from your struct.
  • Ensure the test fails with a descriptive message if the calculated shipping doesn't match the expected value.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.