A table-driven test in Go is a test that defines its cases in a table and iterates over them with a loop.
It’s a widely used technique in Go because it fits perfectly with the language’s simple and explicit nature. Instead of copy-pasting five nearly identical tests, we define the input data, the expected result, and let the loop do the boring work.
Let’s see it with a small example, because that makes it easy to understand right away.
The problem of repeating tests
We have this function:
package calculadora
func EsPar(n int) bool {
return n%2 == 0
}We could write several separate tests:
func TestEsParConDos(t *testing.T) {
if !EsPar(2) {
t.Error("2 debería ser par")
}
}
func TestEsParConTres(t *testing.T) {
if EsPar(3) {
t.Error("3 no debería ser par")
}
}It works, but it starts to smell like repetition. And repetition in tests also has a cost.
Creating the table of cases
The idea is to define a list of cases:
func TestEsPar(t *testing.T) {
casos := []struct {
nombre string
entrada int
esperado bool
}{
{nombre: "dos es par", entrada: 2, esperado: true},
{nombre: "tres es impar", entrada: 3, esperado: false},
{nombre: "cero es par", entrada: 0, esperado: true},
{nombre: "negativo par", entrada: -4, esperado: true},
}
for _, caso := range casos {
resultado := EsPar(caso.entrada)
if resultado != caso.esperado {
t.Errorf("%s: EsPar(%d) = %v; esperado %v",
caso.nombre,
caso.entrada,
resultado,
caso.esperado,
)
}
}
}Now adding a new case is as simple as adding a line to the table.
Subtests with t.Run
We can improve the output by using t.Run, which creates a subtest for each case.
func TestEsPar(t *testing.T) {
casos := []struct {
nombre string
entrada int
esperado bool
}{
{"dos es par", 2, true},
{"tres es impar", 3, false},
{"cero es par", 0, true},
}
for _, caso := range casos {
t.Run(caso.nombre, func(t *testing.T) {
resultado := EsPar(caso.entrada)
if resultado != caso.esperado {
t.Errorf("EsPar(%d) = %v; esperado %v",
caso.entrada,
resultado,
caso.esperado,
)
}
})
}
}This makes the go test output clearer because each case appears with its own name.
Running a specific subtest
We can run a subtest by name:
go test -run TestEsPar/dosThis is very convenient when you have a large table and only one case fails. You don’t have to run the entire package over and over again.
Beware of loop variables
Since Go 1.22, each iteration declared by the range receives its own variable. In modules with an earlier version, a parallel subtest could capture the same variable in every iteration; this was the defensive pattern:
for _, caso := range casos {
caso := caso
t.Run(caso.nombre, func(t *testing.T) {
resultado := EsPar(caso.entrada)
if resultado != caso.esperado {
t.Errorf("resultado incorrecto")
}
})
}That caso := caso creates a new variable for each iteration. It’s not needed in modules with Go 1.22 semantics or later, though it will appear frequently in older code.
Cases with errors
We can also test functions that return errors.
func TestDividir(t *testing.T) {
casos := []struct {
nombre string
a, b int
esperado int
esperaError bool
}{
{"division normal", 10, 2, 5, false},
{"division por cero", 10, 0, 0, true},
}
for _, caso := range casos {
t.Run(caso.nombre, func(t *testing.T) {
resultado, err := Dividir(caso.a, caso.b)
if caso.esperaError && err == nil {
t.Fatal("se esperaba error")
}
if caso.esperaError {
return
}
if err != nil {
t.Fatalf("no se esperaba error: %v", err)
}
if resultado != caso.esperado {
t.Errorf("resultado = %d; esperado %d", resultado, caso.esperado)
}
})
}
}