Skip to main content
Create your own
Lesson illustration

Table-Driven Tests for Preserving Optimized Code Behavior

Hello! This course approaches Go optimization as an evidence-driven process: establish what a program must do, measure it, change one thing, and check that the change helped without breaking behavior. We begin with correctness. A faster function is not an improvement if callers can observe a different result.

In this lesson, you’ll write a table-driven test for a function we might later optimize. You’ll identify its observable contract, turn that contract into named cases, and use the test as a guard against regressions.


Decide what the test must protect

Suppose a package cleans a list of labels. Its CleanLabels function has this contract:

  • Trim surrounding whitespace and convert each label to lowercase.
  • Omit labels that become empty.
  • Keep only the first occurrence of each normalized label, preserving the order of those first occurrences.
  • Do not modify the caller’s input slice.

For this example, an empty result may be either a nil slice or an empty non-nil slice; callers are not promised a distinction. That decision matters. A test should pin down behavior callers rely on, not an incidental choice that an optimization should be free to change.

Dave Cheney’s discussion of Go package boundaries gives a useful way to think about this distinction.

LondonGophers 20/03/2019: Dave Cheney - Absolute Unit (Test)

Watch “Absolute Unit (Test)” by Dave Cheney, presented at LondonGophers, for its distinction between what a package exposes and how it works internally.

Watch observable behavior. Focus on the perspective of a caller using a package: what could that caller detect if the implementation changed?

For CleanLabels, callers can detect output values, output order, and changes to the input they supplied. They cannot detect whether the implementation uses a map, a loop over prior results, or another deduplication strategy—unless that choice changes one of those observable properties. Our test will therefore check the contract, not the data structure.


Turn the contract into table entries

A table-driven test stores cases as data and runs the same checking logic for each one. This makes it inexpensive to add a case when you discover a boundary condition or a past bug. The testing package discovers functions named Test... in files ending _test.go; t.Run gives each table entry its own named subtest.

GopherCon 2017: Advanced Testing with Go - Mitchell Hashimoto

Watch the table-driven-test portion of “Advanced Testing with Go” by Mitchell Hashimoto, presented at GopherCon 2017. It shows how subtests make a collection of inputs and expected outputs readable.

Start with the table pattern, then watch case naming. Notice why a descriptive name is more useful in a failure report than a case number.

Before writing code, it helps to sketch cases against the promises they protect:

Case Input Expected output Behavior protected
Trim and lowercase [" Go "] ["go"] Normalization
Remove blanks ["", " \t ", "go"] ["go"] Filtering after trimming
Deduplicate normalized values ["Go", " go ", "GO"] ["go"] Deduplication uses normalized values
Preserve first-seen order ["beta", "alpha", " BETA ", "gamma"] ["beta", "alpha", "gamma"] Stable output order

Notice that “deduplicate” needs more than two identical strings: the case must distinguish deduplication before normalization from deduplication after it. Likewise, checking order is important because an implementation that builds a map and then iterates over it could retain the right labels without preserving their promised order.

The Go Wiki’s testing guidance adds a restraint: use a table when cases share checking logic. If some cases need fundamentally different checks, separate tests may be clearer than a table full of conditional branches.

Go Wiki: Go Test Comments - The Go Programming Language

Read the Go Wiki’s guidance on when to use table-driven tests and how to make failures identifiable. It will help you keep the example below readable as cases accumulate.

In “Table-Driven Tests vs Multiple Test Functions,” read the comparison, ending with the suggestion to separate normal-output and error-output checks when appropriate. Then, in “Identify the Input” and “Keep Going,” read the failure guidance. Focus on names and messages that let you diagnose a failure without counting table rows.


Write the behavior guard

Here is a small implementation to test. In a new directory, run go mod init example.com/labels, then save this as labels.go. The example uses Go 1.22 or newer for the test code below.

package labels

import "strings"

func CleanLabels(in []string) []string {
	result := make([]string, 0, len(in))
	seen := make(map[string]bool, len(in))

	for _, label := range in {
		normalized := strings.ToLower(strings.TrimSpace(label))
		if normalized == "" || seen[normalized] {
			continue
		}
		seen[normalized] = true
		result = append(result, normalized)
	}
	return result
}

Save the test as labels_test.go in the same directory:

package labels

import (
	"slices"
	"testing"
)

func TestCleanLabels(t *testing.T) {
	tests := []struct {
		name string
		in   []string
		want []string
	}{
		{
			name: "nil_input",
			in:   nil,
			want: nil,
		},
		{
			name: "trim_and_lowercase",
			in:   []string{"  Go  "},
			want: []string{"go"},
		},
		{
			name: "remove_blank_labels",
			in:   []string{"", " \t ", "go"},
			want: []string{"go"},
		},
		{
			name: "deduplicate_after_normalizing",
			in:   []string{"Go", " go ", "GO"},
			want: []string{"go"},
		},
		{
			name: "preserve_first_seen_order",
			in:   []string{"beta", "alpha", " BETA ", "gamma"},
			want: []string{"beta", "alpha", "gamma"},
		},
		{
			name: "keep_distinct_labels",
			in:   []string{"go", "golang"},
			want: []string{"go", "golang"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			before := slices.Clone(tt.in)
			got := CleanLabels(tt.in)

			if !slices.Equal(got, tt.want) {
				t.Errorf(
					"CleanLabels(%q) = %q, want %q",
					before, got, tt.want,
				)
			}
			if !slices.Equal(tt.in, before) {
				t.Errorf(
					"CleanLabels modified input: got %q, want %q",
					tt.in, before,
				)
			}
		})
	}
}

Each row specifies an input and expected output; the loop contains the checking logic only once. slices.Clone takes a snapshot before the call so the second check can detect changes to the caller’s slice. slices.Equal compares elements in order, making output order part of the test. It also treats nil and empty slices with no elements as equal, consistent with the contract we chose.

The failure message includes the function name, input, actual result, and expected result. t.Errorf records a failure but allows the remaining checks and subtests to run, so one test run can reveal more than one regression. These tests do not prove the function correct for every possible input; they preserve specific, deliberately chosen behaviors.


Use the tests before and after a change

Run the package tests with:

go test ./...

For the named cases and their results, use:

go test -v ./...

You can also target one subtest while investigating a failure:

go test -run '^TestCleanLabels$/^preserve_first_seen_order$' -v

Consider what would happen if a proposed optimization removed strings.TrimSpace. The trim_and_lowercase, remove_blank_labels, and deduplicate_after_normalizing cases would expose different consequences of that single change. That is the value of separate named cases: a failure tells you which promise was broken.

When optimizing existing code, establish expected results from the intended contract and known caller requirements—not solely by copying whatever the current implementation returns. If you find surprising current behavior, decide whether it is a behavior to preserve or a bug to fix. Record that decision before changing the implementation; do not automatically rewrite a failing test to make a faster version pass.


Takeaways

A table-driven test is a compact way to express multiple examples of one behavior contract. For optimization work, its strength comes from well-chosen cases, observable expectations, and diagnostic failure messages, not from the table format alone. Here, the tests protect normalization, filtering, deduplication, order, and input ownership while leaving the internal algorithm free to change.

In the next lesson, you’ll write a Go benchmark and learn how to exclude setup and teardown so its timing reflects the work you intend to optimize.

Can't find a good explanation? Sign up and we'll make it for you

Sign up