पाठ 21 / 26

Table-Driven Tests

Many cases, one loop.

testing package and subtests

Tests live in _test.go files with functions TestXxx(t *testing.T). The idiomatic style is table-driven: a slice of cases (name, inputs, expected outputs) and a loop that runs each as a subtest with t.Run, so failures name the exact case. go test -v shows each subtest, -cover reports coverage, -run filters by name, -race adds race detection, and benchmarks (BenchmarkXxx) and fuzz tests (FuzzXxx) use the same tool.

Built-in quality tools

go test, go vet and gofmt are part of the toolchain and used by every Go project.

Three ideas: table-driven tests, vet, gofmt.
Figure 7.1 — Tests, vet and formatting.

The code under test

A discount function with a sentinel error.

package main

import "errors"

var ErrBadCode = errors.New("unknown discount code")

func Discount(total float64, code string) (float64, error) {
	switch code {
	case "":
		return total, nil
	case "SAVE10":
		return total * 0.9, nil
	case "FLAT50":
		if total >= 50 {
			return total - 50, nil
		}
		return total, nil
	}
	return 0, ErrBadCode
}

func main() {}

A table-driven test, run

I ran this with Go 1.27.1 (go test -count=1 -v -cover . 2>&1 | sed -E "s/\(([0-9.]+)s\)/(time)/; s/\t[0-9.]+s(\t|$)/\t(time)\1/" in a module named demo, standard library only). Five cases run as named subtests, including the boundary and the error path, all pass with 100% statement coverage. Timings were replaced with (time) to keep the output stable.

package main

import (
	"errors"
	"testing"
)

func TestDiscount(t *testing.T) {
	tests := []struct {
		name    string
		total   float64
		code    string
		want    float64
		wantErr error
	}{
		{"no code", 100, "", 100, nil},
		{"percent", 100, "SAVE10", 90, nil},
		{"flat at boundary", 50, "FLAT50", 0, nil},
		{"flat below minimum", 40, "FLAT50", 40, nil},
		{"unknown code", 100, "BOGUS", 0, ErrBadCode},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Discount(tt.total, tt.code)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("err = %v, want %v", err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("Discount(%v, %q) = %v, want %v", tt.total, tt.code, got, tt.want)
			}
		})
	}
}

Output:

=== RUN   TestDiscount
=== RUN   TestDiscount/no_code
=== RUN   TestDiscount/percent
=== RUN   TestDiscount/flat_at_boundary
=== RUN   TestDiscount/flat_below_minimum
=== RUN   TestDiscount/unknown_code
--- PASS: TestDiscount (time)
    --- PASS: TestDiscount/no_code (time)
    --- PASS: TestDiscount/percent (time)
    --- PASS: TestDiscount/flat_at_boundary (time)
    --- PASS: TestDiscount/flat_below_minimum (time)
    --- PASS: TestDiscount/unknown_code (time)
PASS
coverage: 100.0% of statements
ok  	demo	(time)	coverage: 100.0% of statements

त्वरित जाँच: Why use t.Run for each table case?

  • It makes tests run in production
  • Each case becomes a named subtest, so failures point to the exact case
  • It disables coverage
  • It is required to compile
Answer

Each case becomes a named subtest, so failures point to the exact case — Named, isolated cases.