JavaRush /Courses /Go SELF /Table-driven tests — cases, names, readable errors

Table-driven tests — cases, names, readable errors

Go SELF
Level 29, Lesson 1
Available

1. Why table-driven tests are useful

If you are writing tests for the first time, the natural strategy is: “well, I have three scenarios — I’ll write three tests.” That’s fine. The problem shows up a bit later, when scenarios become 10, 20, 50… and you suddenly discover your tests are growing like a developer’s beard before a release: fast, unevenly, and for some reason in the most inconvenient places.

Let’s imagine that in our training app todo we have validation for a task title. While requirements are small, we can write tests “straight on.”

// title.go
package todo

import "errors"

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

func ValidateTitle(s string) error {
	if s == "" {
		return ErrEmptyTitle
	}
	return nil
}

And “manual” tests:

// title_test.go
package todo

import "testing"

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

import "testing"

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

So far everything looks decent. But as soon as you add scenarios like " " (spaces only), a title that’s too long, forbidden characters, “a semicolon at the end because I feel like it,” — you will either start cloning copy-paste tests, or you’ll stop adding tests altogether (the second happens more often, but we’ll pretend we didn’t notice).

And that’s where the table-driven approach comes in: cases become data, and the test becomes an “engine” that runs all cases the same way.

2. How a table-driven test is structured

Cases as data in []struct{...}

A table-driven test in Go is a deal with yourself: “I describe scenarios compactly, and I write the repetitive checking mechanics once.” And Go is especially convenient here: you already know struct literals and slices, for range too, so there won’t be any magic.

Let’s start with the smallest “table”: input and whether we expect an error.

// title_test.go
package todo

import "testing"

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

	for i, tt := range tests {
		err := ValidateTitle(tt.in)
		if tt.wantErr && err == nil {
			t.Errorf("case %d: ValidateTitle(%q): expected error, got nil", i, tt.in)
		}
		if !tt.wantErr && err != nil {
			t.Errorf("case %d: ValidateTitle(%q): unexpected error: %v", i, tt.in, err)
		}
	}
}

Notice a couple of things.

First, []struct{...} is a type-safe table. You’re not dragging around a map[string]any and trying to remember what you put in there (and where you lost it). The struct fields are the “columns” of your table.

Second, we added case %d via the i index, because without it the error message often looks like “it doesn’t work.” The index is the cheapest way to understand which scenario failed.

But an index isn’t the most readable, and next we’ll learn to give cases names.

Case names: so failures read like a report

When a test fails, your goal is to understand in minimal time: which scenario broke and what exactly didn’t match. An index like case 3 is better than nothing, but a week later you’ll look at it the same way you look at someone else’s code without comments: “who did this and why wasn’t I warned?”

So the next step is to add a name field and use it in messages. This is not subtests (we won’t touch them yet); it’s just a human label.

package todo

import "testing"

func TestValidateTitle_Table_Names(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 {
		err := ValidateTitle(tt.in)
		if tt.wantErr && err == nil {
			t.Errorf("%s: ValidateTitle(%q): expected error, got nil", tt.name, tt.in)
		}
		if !tt.wantErr && err != nil {
			t.Errorf("%s: ValidateTitle(%q): unexpected error: %v", tt.name, tt.in, err)
		}
	}
}

Now if the test fails, you’ll see something like:

  • empty: ValidateTitle(""): expected error, got nil

And that already looks like a normal report.

A separate note about %q. For strings it’s almost always better than %s, because an empty string, spaces, and special characters become visible. In tests, this saves your nerves.

Error checks: wantErr and the meaning of an error

Errors in Go are values, and the idiom is to work with them deliberately: check err != nil, sometimes compare to a “known” error, or find it in a chain.

In a table-driven test it’s important not to mix two worlds: “an error is expected” and “an error is not expected.” The most common beginner breakage is continuing to check the result even when the error is already “not the one.” Ideally, the logic should read like this:

  1. If an error was expected, check that err != nil, and that’s the end of this scenario.
  2. If an error was not expected, check that err == nil, and only then check the result.

Let’s do an example where we check the meaning of the error, not just the fact that it exists.

First, let’s strengthen our validation: let it return different errors.

// title.go
package todo

import "errors"

var (
	ErrEmptyTitle   = errors.New("empty title")
	ErrTooLongTitle = errors.New("title too long")
)

func ValidateTitle(s string) error {
	if s == "" {
		return ErrEmptyTitle
	}
	if len(s) > 10 {
		return ErrTooLongTitle
	}
	return nil
}

Now a table-driven test might look like this:

// title_test.go
package todo

import (
	"errors"
	"testing"
)

func TestValidateTitle_Errors(t *testing.T) {
	tests := []struct {
		name string
		in   string
		want error
	}{
		{name: "ok", in: "read", want: nil},
		{name: "empty", in: "", want: ErrEmptyTitle},
		{name: "too_long", in: "read book now", want: ErrTooLongTitle},
	}

	for _, tt := range tests {
		err := ValidateTitle(tt.in)
		if !errors.Is(err, tt.want) {
			t.Errorf("%s: ValidateTitle(%q): err=%v, want %v", tt.name, tt.in, err, tt.want)
		}
	}
}

Why is this convenient?

If want == nil, then errors.Is(err, nil) will return true only when err == nil. That means one check covers both “no error” and “the error must be a specific one.” Compact.

Why not compare error text? Because the text is a UX detail. You can change the wording (“empty title” → “title is empty”), and the test will fail even though the logic didn’t break. Usually that’s not what you want from a unit test.

A function like (int, error): got/want without extra noise

A very typical scenario in Go is a function that returns a value and an error. In our todo it might be parsing a task priority from a string: in a CLI, in HTTP, in a file — anywhere.

Let’s make a simple function:

// 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: %w", err)
	}
	if n < 0 {
		return 0, fmt.Errorf("priority must be >= 0")
	}
	return n, nil
}

Now the test. What matters here: we don’t want to compare the entire err “as a string,” but we do want to clearly separate scenarios. For a learning level, it’s convenient to start with wantErr bool, and check values only when there should be no error.

// priority_test.go
package todo

import "testing"

func TestParsePriority_Table(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    int
		wantErr bool
	}{
		{name: "zero", in: "0", want: 0, wantErr: false},
		{name: "positive", 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 {
		got, err := ParsePriority(tt.in)

		if tt.wantErr {
			if err == nil {
				t.Errorf("%s: ParsePriority(%q): expected error, got nil", tt.name, tt.in)
			}
			continue
		}

		if err != nil {
			t.Errorf("%s: ParsePriority(%q): unexpected error: %v", tt.name, tt.in, err)
			continue
		}

		if got != tt.want {
			t.Errorf("%s: ParsePriority(%q)=%d, want %d", tt.name, tt.in, got, tt.want)
		}
	}
}

Here, the two continues are not “complexity for complexity’s sake,” but a way to make the report cleaner. If a scenario has an unexpected error, comparing got with want is pointless: got might be zero simply because we agreed to return that value on error. Without continue you can easily get a “double” failure for one case, and the report becomes noisy.

Readable error messages: what to write in t.Errorf

A message in t.Errorf is not a formality. It’s your debug log that shows up exactly when everything is already bad, time is short, and the coffee is gone. A good message should answer three questions: “which case,” “what input,” “what did we get and what did we expect.”

Let’s start with a general template: “function(input)=got, want want”:

t.Errorf("%s: ParsePriority(%q)=%d, want %d", tt.name, tt.in, got, tt.want)

For errors it’s almost always useful to write unexpected error: %v and print the err itself, so you can see context (for example, from fmt.Errorf("...: %w", err)).

t.Errorf("%s: ParsePriority(%q): unexpected error: %v", tt.name, tt.in, err)

One more small but important detail: when printing strings, use %q. This is especially important for “empty” and “spaces.”

t.Errorf("%s: ValidateTitle(%q): expected error, got nil", tt.name, tt.in)

Finally, if a case has multiple inputs, print them all. Seriously. You won’t remember them “by case name” once you have 30 cases.

Mini example: assembling a todo task

So that table-driven tests aren’t “tests for the sake of tests,” let’s connect this to a small piece of business logic. We have a task and user input: a title and a priority as a string. It’s convenient to have one function that does “read → parse → validate → assemble the struct.”

First, the model:

// task.go
package todo

type Task struct {
	Title    string
	Priority int
}

Now the build function:

// build.go
package todo

import "fmt"

func BuildTask(title, priorityStr string) (Task, error) {
	if err := ValidateTitle(title); err != nil {
		return Task{}, fmt.Errorf("title: %w", err)
	}
	p, err := ParsePriority(priorityStr)
	if err != nil {
		return Task{}, fmt.Errorf("priority: %w", err)
	}
	return Task{Title: title, Priority: p}, nil
}

And a table-driven test that checks both “success” and “error expected.” Here we won’t obsess over exact error types (we already wrap them), so we’ll keep the check at the “error should/shouldn’t happen” level, and for success we’ll verify everything.

// build_test.go
package todo

import "testing"

func TestBuildTask_Table(t *testing.T) {
	tests := []struct {
		name    string
		title   string
		prioStr string
		want    Task
		wantErr bool
	}{
		{name: "ok", title: "read", prioStr: "2", want: Task{Title: "read", Priority: 2}},
		{name: "bad_title", title: "", prioStr: "2", wantErr: true},
		{name: "bad_prio", title: "read", prioStr: "x", wantErr: true},
	}

	for _, tt := range tests {
		got, err := BuildTask(tt.title, tt.prioStr)

		if tt.wantErr {
			if err == nil {
				t.Errorf("%s: expected error, got nil", tt.name)
			}
			continue
		}

		if err != nil {
			t.Errorf("%s: unexpected error: %v", tt.name, err)
			continue
		}

		if got != tt.want {
			t.Errorf("%s: got=%v, want=%v", tt.name, got, tt.want)
		}
	}
}

Notice: we could compare Task via != because the struct contains only comparable fields (string and int). This is a very nice bonus for tests: got/want reads simpler.

And here’s a small diagram of how the “test engine” works with the table:

flowchart TD
    A[Take case tt from tests] --> B[Call function: got, err]
    B --> C{tt.wantErr?}
    C -->|yes| D{err == nil?}
    D -->|yes| E[Error: expected error, got nil]
    D -->|no| F[Case passed]
    C -->|no| G{err != nil?}
    G -->|yes| H[Error: unexpected error]
    G -->|no| I{got == want?}
    I -->|no| J[Error: got vs want]
    I -->|yes| K[Case passed]

This logic is almost the same everywhere. Once you “get it into your hands,” table-driven tests start getting written very quickly.

3. Common mistakes with table-driven tests

Mistake #1: not adding a case identifier to messages.
When a check fails inside a loop, without name or at least case %d you get a message with no context and are forced to guess manually which scenario it was. This is especially painful when inputs are similar. The fix is simple: always print tt.name or i.

Mistake #2: mixing “error expected” and “no error expected” scenarios in one branch.
Beginners often compare got first, then check err, and sometimes they forget that on error got can be a “placeholder.” It’s better to decide the fate of err first: if an error is expected, check it and exit the case; if not, ensure err == nil, and only then compare got with want.

Mistake #3: comparing error text as the primary way to validate.
Error text can change without changing meaning, especially if you add context via fmt.Errorf("...: %w", err). It’s more stable to check “error/no error,” and if you need to validate meaning, use a recognizable (sentinel) error and errors.Is, rather than err.Error() == "...".

Mistake #4: making “fat” cases with 15 fields.
When the case struct becomes huge, the test becomes hard to read: you can’t see how scenarios differ. A good table-driven test usually contains only the fields that actually participate in the scenario. If there are many fields, it’s a signal that you’re testing too much logic at once, and you should simplify the function under test a bit or split the checks.

Mistake #5: using t.Fatalf inside the loop and accidentally “cutting off” the other cases.
Sometimes Fatal is appropriate, but in a table-driven test without running each case separately (since we’re not using subtests yet) t.Fatalf stops the entire test, and you’ll only see the first problem. More often you want to collect several failures in one run. That’s why inside the loop people typically use t.Errorf and continue, keeping Fatal for truly “impossible” situations.

1
Task
Go SELF, level 29, lesson 1
Locked
Sum board
Sum board
1
Task
Go SELF, level 29, lesson 1
Locked
Range limiter
Range limiter
1
Task
Go SELF, level 29, lesson 1
Locked
Survey age
Survey age
1
Task
Go SELF, level 29, lesson 1
Locked
Username validation
Username validation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION