JavaRush /Courses /Go SELF /Subtests t.Run and t.Helper: maintainable tests

Subtests t.Run and t.Helper: maintainable tests

Go SELF
Level 29, Lesson 2
Available

1. Introduction

When you only have a few tests, the most common flow looks like this: a test fails, you open the file, skim it with your eyes—and everything is clear. But as soon as you end up with “a dozen cases for one function”, an unpleasant effect appears: the test fails, you see the message, but it does not always immediately answer the question “which exact scenario broke?”. Especially when the message is printed without the case name—only with an index or input data that you still have to interpret mentally.

Subtests are a way to turn one “big test with a loop” into a tree of tests: the main test TestXxx becomes a container, and each case runs as a child test with its own name. In the test runner report you will see not just “TestXxx failed”, but for example “TestParsePriority/negative failed”—and that is already very close to a diagnosis.

In Go, subtests are created via the method t.Run(name, func(t *testing.T) { ... }). It is important that a new t is passed inside, and it belongs specifically to this case. That means errors, failures, and messages will be tied to the specific subtest, not dumped into “one shared pile”.

The problem without subtests: an index instead of a scenario

Let’s start with an honest table-driven test, but without t.Run. Formally it is correct, and it can even be convenient with a small number of cases. But as soon as the number of cases grows, the report becomes less friendly.

// priority_test.go
package todo

import "testing"

func TestParsePriority_Table_NoSubtests(t *testing.T) {
	tests := []struct {
		in      string
		want    int
		wantErr bool
	}{
		{in: "0", want: 0, wantErr: false},
		{in: "5", want: 5, wantErr: false},
		{in: "-1", want: 0, wantErr: true},
		{in: "x", want: 0, wantErr: true},
	}

	for i, tt := range tests {
		got, err := ParsePriority(tt.in)

		if tt.wantErr {
			if err == nil {
				t.Fatalf("case %d: ParsePriority(%q): expected error, got nil", i, tt.in)
			}
			continue
		}

		if err != nil {
			t.Fatalf("case %d: ParsePriority(%q): unexpected error: %v", i, tt.in, err)
		}
		if got != tt.want {
			t.Fatalf("case %d: ParsePriority(%q)=%d, want %d", i, tt.in, got, tt.want)
		}
	}
}

The problem here is not the logic, but the “test UX”. If the test fails, you will see case 2, and you need either to remember what that case is, or open the table and search. Yes, we already print the input tt.in, and that helps—but the report still does not say “negative” or “invalid number”.

2. Mini context: what we test in todo

To make the examples feel like the evolution of a single application, let’s agree that we have a package todo with basic logic. Suppose it contains two functions: ValidateTitle and ParsePriority. They are simple, but perfect for demonstrating subtests: many scenarios, the same verification structure.

// title.go
package todo

import "errors"

var ErrEmptyTitle = errors.New("empty title")

func ValidateTitle(s string) error {
	if s == "" {
		return ErrEmptyTitle
	}
	return nil
}
// priority.go
package todo

import (
	"fmt"
	"strconv"
)

func ParsePriority(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse priority: %v", err)
	}
	if n < 0 {
		return 0, fmt.Errorf("priority must be >= 0")
	}
	return n, nil
}

Right now we are not interested in an “ideal domain model”, but in tests: how to make them readable and easy to maintain.

3. t.Run: subtests, names, and grouping

Now let’s do the same thing, but run each case as a subtest. To do that, we add a name field and wrap the case body in t.Run.

// priority_test.go
package todo

import "testing"

func TestParsePriority_Subtests(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    int
		wantErr bool
	}{
		{name: "zero", in: "0", want: 0, wantErr: false},
		{name: "ok", in: "5", want: 5, wantErr: false},
		{name: "negative", in: "-1", want: 0, wantErr: true},
		{name: "not_a_number", in: "x", want: 0, wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := ParsePriority(tt.in)

			if tt.wantErr {
				if err == nil {
					t.Fatalf("ParsePriority(%q): expected error, got nil", tt.in)
				}
				return
			}

			if err != nil {
				t.Fatalf("ParsePriority(%q): unexpected error: %v", tt.in, err)
			}
			if got != tt.want {
				t.Fatalf("ParsePriority(%q)=%d, want %d", tt.in, got, tt.want)
			}
		})
	}
}

There are two key benefits here:

  • Scenario name. You no longer read case 2, you read negative.
  • Report isolation. These are now separate subtests, and messages do not “stick together” into one long wall of text.

If you have come across the line tt := tt before t.Run in existing code, it is a holdover from older Go versions. Before Go 1.22, there was one loop variable for the whole loop, and a subtest that ran later than its iteration (for example, a parallel one) could read the “wrong” case. Starting with Go 1.22, each iteration of for _, tt := range gets its own tt variable. The new semantics is enabled by the go directive in go.mod: ours says go 1.25, so the copy is not needed.

Subtests as a tree: when it is convenient to group scenarios

When there are a lot of scenarios, it can be useful to add a sub-level: for example, “valid inputs” separately and “invalid inputs” separately. This is not required, but sometimes it makes the picture even clearer: you can see that it is not “something” that breaks, but specifically the invalid branch.

// title_test.go
package todo

import "testing"

func TestValidateTitle_Groups(t *testing.T) {
	t.Run("valid", func(t *testing.T) {
		if err := ValidateTitle("read book"); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("invalid", func(t *testing.T) {
		if err := ValidateTitle(""); err == nil {
			t.Fatalf("expected error, got nil")
		}
	})
}

This style is especially nice when the function under test has multiple “categories” of behavior. It is like drawing mini documentation: “valid works like this, invalid works differently”.

Visual scheme: how a test turns into a tree

When you see subtests for the first time, your brain may perceive it as “a loop inside a test inside a test” (and that is true). It helps to keep a simple scheme in mind: TestXxx is the root, and t.Run adds child nodes.

flowchart TD
    A["TestParsePriority"] --> B["subtest: zero"]
    A --> C["subtest: ok"]
    A --> D["subtest: negative"]
    A --> E["subtest: not_a_number"]

In the test runner report, you see exactly this structure: parent and children.

4. t.Helper(): helper functions and readable failures

When you write a lot of tests, you end up with repetitive code: “check that there is no error”, “check that there is an error”, “compare got and want”. You want to extract it into functions; otherwise tests turn into a sheet of identical checks.

But as soon as you move assertions into a helper function, an annoyance appears: if the test fails inside the helper, the test runner will show you the line inside the helper function, not the line in the test where the helper was called. As a result, you jump into the helper, then back, and your brain wastes extra seconds.

Go provides t.Helper() for this. It marks a function as “helper”, and when printing file/line information the test runner will point to the helper call site, not the internals of the helper function.

Writing small helper functions

A very common beginner mistake is to make one giant helper like assertEverything(...) that knows too much. In Go, the style is usually different: it is better to have several small helpers, each doing one simple check.

Let’s start with two classic ones: “there should be no error” and “there should be an error”.

// helpers_test.go
package todo

import "testing"

func requireNoError(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}

func requireError(t *testing.T, err error) {
	t.Helper()
	if err == nil {
		t.Fatalf("expected error, got nil")
	}
}

Note that t.Helper() is the first line. This matters: we proactively tell the test runner “do not treat this function as the error location”.

Now the tests become shorter and more readable.

// title_test.go
package todo

import "testing"

func TestValidateTitle_UsesHelpers(t *testing.T) {
	requireNoError(t, ValidateTitle("read book"))
	requireError(t, ValidateTitle(""))
}

Combo: table-driven + subtests + helpers

Now let’s assemble the “combo”: table-driven cases + subtests + helper functions. This is one of the most popular and practical testing patterns in Go, because it gives you compactness, a clear report, and minimal noise at the same time.

// title_test.go
package todo

import "testing"

func TestValidateTitle_Table_Subtests(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		wantErr bool
	}{
		{name: "ok", in: "read book", wantErr: false},
		{name: "empty", in: "", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := ValidateTitle(tt.in)

			if tt.wantErr {
				requireError(t, err)
				return
			}
			requireNoError(t, err)
		})
	}
}

This test is easy to extend: you add a new case, and it automatically becomes a separate subtest. At the same time, the checks read the same way in each case, and you do not need to copy the same assertion ten times.

Maintainable tests: the practical effect

When people say “maintainable tests”, they usually mean that tests not only catch bugs, but also help you fix them. In practice, maintainability most often breaks in two places:

  • you cannot quickly understand which scenario failed;
  • you cannot quickly understand where exactly the test failed (because everything fails “inside a helper” or “inside a shared function”).

Subtests solve the first problem: the scenario becomes the name. Helper functions with t.Helper() solve the second: the failure location points to the call line, not the internals of a technical function.

In plain human terms: t.Run and t.Helper() are two tools that make tests “talk” to you in a clear language rather than riddles.

5. Common mistakes when working with t.Run and t.Helper()

Mistake #1: subtests without meaningful names.
Sometimes people write name: "case1", name: "case2". Formally this works, but the point of subtests is lost: you are back to “indexes”, just in profile. A good name is a short description of the scenario: empty, negative, ok, not_a_number. It should help you understand what exactly broke without opening the code.

Mistake #2: the case variable is declared outside the loop.
If you declare tt before the loop and write for _, tt = range tests (with = rather than :=), there is a single variable for the whole loop. While the subtests run sequentially, this goes unnoticed. But add t.Parallel(), and all subtests will read the last case. Declare the variable right in the loop header: for _, tt := range tests. Then in Go 1.22+ each iteration has its own variable, and the line tt := tt is not needed.

Mistake #3: a helper function does not call t.Helper().
You moved a check into requireNoError, the test failed, and now you are shown the line inside requireNoError, not the line in the test. It feels like a small detail, but on large test suites it really slows down debugging. t.Helper() exists precisely so the test runner points to the helper call site.

Mistake #4: helper functions turn into a “super combine harvester”.
When a helper starts taking ten parameters and deciding for you what should be considered an error, tests become shorter, but understanding drops. In Go, the style of small helpers usually wins: requireNoError, requireError, assertEqualInt. Then tests remain “readable like a story”, and helpers do not hide meaning behind overly clever logic.

Mistake #5: using t.Error* where t.Fatal* is needed (and vice versa) inside subtests.
Inside a single subtest, if a condition is critical (for example, you expected err == nil but got an error), continuing checks is often pointless: subsequent got/want are already invalid. Then t.Fatalf is better. If checks are independent (rare, but it happens), you can use t.Errorf and collect several errors in one run. The key is that this is intentional, not accidental.

1
Task
Go SELF, level 29, lesson 2
Locked
Sign of a Number
Sign of a Number
1
Task
Go SELF, level 29, lesson 2
Locked
Discount coupon
Discount coupon
1
Task
Go SELF, level 29, lesson 2
Locked
Server port
Server port
1
Task
Go SELF, level 29, lesson 2
Locked
Full Name Parsing
Full Name Parsing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION