Test Go Code That Uses Generics Without Duplication
Test generic Go code once for every type: a helper with a type parameter, subtests per type, defined types behind a tilde constraint, and typed table rows.

Generic code has a testing problem that ordinary code does not: it must be correct for every type it accepts, and you cannot test every type. The usual answer is to pick a few representative ones and write the tests once. The usual mistake is copying a test body per type, which makes four tests that drift apart.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows the patterns that avoid the copying on Go 1.25.6: a generic test helper, subtests per type, tables that carry the type, and the cases that are easy to forget.
The code
package main
import (
"cmp"
"slices"
)
type Stack[T any] struct{ items []T }
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var t T
for _, x := range xs {
t += x
}
return t
}
func MaxOf[T cmp.Ordered](xs []T) (T, bool) {
if len(xs) == 0 {
var zero T
return zero, false
}
return slices.Max(xs), true
}
func main() {}A Stack[T any], a Sum over a numeric constraint with tilde types, and a MaxOf over cmp.Ordered that returns a boolean for the empty case. They are small, and they cover the constraints you meet most.
Write the behaviour once
package main
import (
"fmt"
"testing"
)
type Celsius float64
type point struct{ X, Y int }
// testStack is the behaviour every Stack[T] must have, written once.
func testStack[T comparable](t *testing.T, a, b T) {
t.Helper()
var s Stack[T]
if _, ok := s.Pop(); ok {
t.Error("pop on empty stack succeeded")
}
s.Push(a)
s.Push(b)
if got, _ := s.Pop(); got != b {
t.Errorf("popped %v, want %v", got, b)
}
if got, _ := s.Pop(); got != a {
t.Errorf("popped %v, want %v", got, a)
}
}
func TestStack(t *testing.T) {
t.Run("int", func(t *testing.T) { testStack(t, 1, 2) })
t.Run("string", func(t *testing.T) { testStack(t, "a", "b") })
t.Run("struct", func(t *testing.T) { testStack(t, point{1, 2}, point{3, 4}) })
t.Run("pointer", func(t *testing.T) { x, y := 1, 2; testStack(t, &x, &y) })
}
func TestSumAcrossTypes(t *testing.T) {
// Each row has its own type, so the table is a list of closures rather than a slice of structs.
cases := []struct {
name string
run func() string
}{
{"int", func() string { return fmt.Sprint(Sum([]int{1, 2, 3})) }},
{"float64", func() string { return fmt.Sprint(Sum([]float64{0.5, 0.25})) }},
{"Celsius", func() string { return fmt.Sprintf("%v %T", Sum([]Celsius{10, 12.5}), Celsius(0)) }},
{"empty", func() string { return fmt.Sprint(Sum([]int(nil))) }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Logf("%s", c.run()) })
}
}
func TestMaxOfZeroValues(t *testing.T) {
v, ok := MaxOf([]string(nil))
t.Logf("empty strings: %q ok=%v", v, ok)
f, ok := MaxOf([]float64{-1, -0.5})
t.Logf("negative floats: %v ok=%v", f, ok)
}
// typed table: a generic helper lets one struct type serve every element type.
type tc[T any] struct {
name string
in []T
want T
}
func runSum[T Number](t *testing.T, cases []tc[T]) {
t.Helper()
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Sum(c.in); got != c.want {
t.Errorf("Sum(%v) = %v, want %v", c.in, got, c.want)
}
})
}
}
func TestSumTyped(t *testing.T) {
runSum(t, []tc[int]{{"ints", []int{1, 2}, 3}, {"none", nil, 0}})
runSum(t, []tc[float64]{{"floats", []float64{0.5, 0.25}, 0.75}})
}go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #'--- PASS: TestStack
--- PASS: TestStack/int
--- PASS: TestStack/string
--- PASS: TestStack/struct
--- PASS: TestStack/pointer
6
0.75
22.5 main.Celsius
0
--- PASS: TestSumAcrossTypes
--- PASS: TestSumAcrossTypes/int
--- PASS: TestSumAcrossTypes/float64
--- PASS: TestSumAcrossTypes/Celsius
--- PASS: TestSumAcrossTypes/empty
empty strings: "" ok=false
negative floats: -0.5 ok=true
--- PASS: TestMaxOfZeroValues
--- PASS: TestSumTyped
--- PASS: TestSumTyped/ints
--- PASS: TestSumTyped/none
--- PASS: TestSumTyped/floatstestStack[T comparable] takes two distinct values of type T and checks the behaviour of a stack: empty pop fails, last in comes out first. It is an ordinary function with a type parameter, and t.Helper() makes failures point at the calling line. TestStack calls it four times, each in a subtest named for the type: int, string, a struct and a pointer.
The type argument is inferred from the arguments, so there is no testStack[int](...). The constraint on the helper, comparable, is what lets it compare got != b; a helper for types that are not comparable should take an equality function or use reflect.DeepEqual, or cmp.Equal from go-cmp.
Choose the types to cover the ways a type can differ, not just the count. A small integer, a string (its own representation and a pointer inside), a struct (copied by value), and a pointer (identity, not value) behave differently in generic code even when the logic does not mention them. That is the useful set.
A defined type behind a tilde
Sum accepts ~float64, so a defined type Celsius with underlying type float64 works. The log line 22.5 main.Celsius shows both that it computed 10 + 12.5 correctly and that the type survived. Include a defined type whenever the constraint has a tilde, because without it a bad constraint such as float64 alone would pass every test written with built-in types and reject callers' defined types at compile time. A defined type is the cheapest way to prove the tilde is there.
Tables with a type parameter
A table-driven test usually uses a slice of structs, but a struct has one type for its fields, so a table cannot mix []int and []float64 cases. There are two ways around it.
The first is closures, as in TestSumAcrossTypes: each row has a name and a function that runs its own typed call and returns a string. It is flexible and loses type checking on the expected value.
The second is a generic row type. tc[T] holds a name, an input and the expected output, and runSum[T Number] runs a table of []tc[T]. TestSumTyped calls it once for ints and once for floats. Failure messages come from one place and the expected values keep their type. The general form is one table per element type and one runner, which is the one to prefer once there are more than a few cases per type. See the table-driven tests article for the base pattern.
The cases people forget
The zero value. MaxOf([]string(nil)) returned the empty string with ok=false. For T it is the zero value of T: 0, "", nil, a struct of zeros. A caller who ignores the boolean gets a plausible-looking value. Test the empty path for at least one type whose zero value is a legitimate answer, such as int.
Negative and boundary values. MaxOf([]float64{-1, -0.5}) returned -0.5. An implementation that started from a zero value instead of the first element would return 0, and the all-negative case catches it, for any ordered type.
Types that break assumptions. NaN among floats is not equal to itself and sorts unpredictably; a type with a custom ==; a pointer type, where equal-looking values are different. If your function says comparable or Ordered, test with a float that has NaN if the semantics matter.
Instantiation errors. Some generic bugs are compile errors for particular types. A negative test for those is not a Go test: put a snippet in a comment, or build it with go vet in a separate check. What a unit test can do is cover every constraint branch with at least one type.
How many types is enough
Test the shapes, not the list. For a container: one value type, one reference type, one struct, one zero-size type if the code allocates. For a numeric constraint: an integer, a float and a defined type. For cmp.Ordered: an integer and a string. If a bug is type-specific it is usually about representation, such as a copy that is shallow for a struct with a slice field. A property test with a generator for each type is the next step, as in property based testing.
Helpers shared across packages
A generic test helper is useful beyond one file. If a container type is implemented by several packages, put the behaviour check in an exported function in an internal test support package and call it from each implementation's tests with that package's type. The helper becomes a conformance suite: one definition of what a correct stack, cache or set does, run against every implementation. The shared helpers article covers how to arrange that package without an import cycle.
Where generics testing stops
The compiler checks the constraint, so you do not test that an int can be added. You test the logic. Generic code is also specialised by the compiler: the runtime representation is shared among some types and distinct among others (GC shape stenciling), so a bug in how the code treats a pointer versus a value can show up in only one group. That is the reason for covering both. The language specification describes type parameters and constraints exactly, and the Go blog has an introduction to generics with the reasoning behind the tilde.


