Menu

Testing en Golang: tests unitarios, tests de tabla y benchmarks

Cómo testear código Go con el paquete estándar testing y go test: archivos _test.go, funciones TestXxx, t.Errorf frente a t.Fatalf, tests de tabla con t.Run, helpers y directorios temporales, cobertura, benchmarks con b.Loop y tests de ejemplo.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

El código que se prueba

Las herramientas de testing de Go forman parte de la toolchain estándar: el paquete testing y el comando go test. No hay framework que instalar ni hace falta una librería de aserciones.

Los ejemplos de esta página prueban una pequeña función, Slugify, que convierte un título en un slug para una URL. Aquí está, ejecutable, con una comprobación hecha a mano en main:

El editor del navegador ejecuta main; no puede ejecutar go test. Todo lo que sigue está escrito tal como vive en un proyecto real, con la salida de la terminal después de cada comando. Todo se ejecutó con Go 1.24.

El primer test

En un proyecto, la función vive en un paquete, y sus tests viven a su lado en un archivo cuyo nombre termina en _test.go:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

Las reglas que usa go test:

  • Solo los archivos que terminan en _test.go son archivos de test. go build los ignora, así que el código de test nunca acaba en tu binario.
  • Un test es una función llamada TestXxx (la parte tras Test no puede empezar por minúscula) que recibe un *testing.T.
  • Un test pasa salvo que llame a uno de los métodos de fallo o provoque un panic.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

El formato del mensaje de fallo Func(input) = got, want expected es la convención de Go. Pon la entrada en el mensaje: un fallo que solo dice got "a", want "b" te obliga a volver al código para averiguar qué entrada era.

t.Errorf frente a t.Fatalf

MétodoMarca como fallidoDetiene el test
t.Error, t.Errorfno, sigue ejecutándose
t.Fatal, t.Fatalfsí, de inmediato
t.Log, t.Logfnono, solo imprime con -v o si falla
t.Skip, t.Skipfnosí, se informa como omitido

Usa Errorf por defecto, para que una sola ejecución informe de todos los campos incorrectos. Usa Fatalf cuando el resto del test no pueda funcionar, normalmente tras un error inesperado:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatal detiene el test llamando a runtime.Goexit, así que hay que llamarlo desde la propia goroutine del test. Desde una goroutine que hayas lanzado, informa con t.Error y retorna.

Tests de tabla con t.Run

La mayoría de los tests de Go son tablas: un slice de casos, un bucle y t.Run para dar a cada caso su propio nombre.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

Añadir un caso es una línea. Cada subtest se informa por separado, un Fatal dentro de uno termina solo ese subtest, y puedes ejecutar uno solo por su nombre. Ejecútalo todo con -v:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

Los espacios en los nombres de los subtests se convierten en guiones bajos. Cuando un caso falla, la salida nombra el subtest y la línea:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

Desde Go 1.22 cada iteración del bucle tiene su propio tt, así que las closures dentro de t.Run ven el caso correcto aunque los subtests se ejecuten en paralelo. El código antiguo suele tener tt := tt al principio del cuerpo del bucle por ese motivo; ya no hace falta.

Para ejecutar subtests en paralelo, llama a t.Parallel() al principio de la función del subtest. Hazlo solo cuando los casos sean independientes y lo bastante lentos como para que importe.

Flags de go test que vas a usar

ComandoQué hace
go test -vimprime el nombre, el resultado y la salida de t.Log de cada test
go test -run TestSlugifyejecuta los tests cuyo nombre coincide con la expresión regular
go test -run 'TestSlugifyTable/empty'ejecuta un subtest
go test -count=1ignora los resultados en caché y vuelve a ejecutar
go test -raceejecuta con el detector de carreras de datos
go test -shortpide a los tests largos que se omitan (if testing.Short() { t.Skip() })
go test -failfastse detiene tras el primer test que falla
go test -timeout 30sfalla si la ejecución tarda más (por defecto 10 minutos)
go test -coverimprime la cobertura de sentencias
go test -bench=.ejecuta también los benchmarks

Cuando nombras paquetes (go test ., go test ./...), go test guarda en caché los resultados de los paquetes cuyo código y entradas no han cambiado e imprime (cached) detrás. Un go test sin argumentos nunca usa la caché. La caché sigue los archivos y variables de entorno que un test lee a través del paquete os, pero no el estado externo como una base de datos o un servicio de red, así que un test que depende de uno puede pasar desde la caché cuando ahora fallaría; -count=1 fuerza una ejecución real.

Helpers, directorios temporales y limpieza

La llamada a writeFile de TestCountWords es un helper de test:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper() marca la función como helper, así que un fallo se informa en la línea del test que la llamó, no dentro del helper.
  • t.TempDir() crea un directorio nuevo y lo borra cuando termina el test. Los tests nunca tienen que limpiar sus archivos.
  • t.Cleanup(func() { ... }) registra cualquier otra tarea de limpieza (cerrar un servidor, borrar una tabla). Las limpiezas se ejecutan después del test y de sus subtests, la última registrada primero.
  • t.Setenv("KEY", "value") define una variable de entorno solo para este test y la restaura después.
  • t.Context() (Go 1.24) devuelve un context que se cancela justo antes de ejecutar las limpiezas.

Los archivos de datos de prueba van en un directorio llamado testdata junto a los tests. La herramienta go no lo trata como paquete, y los tests se ejecutan con el directorio del paquete como directorio de trabajo, así que os.ReadFile("testdata/input.json") funciona.

Probar errores y handlers HTTP

Comprueba los errores con errors.Is o errors.As, igual que lo haría el código que llama:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

Comparar strings de error se rompe en cuanto alguien cambia la redacción de un mensaje.

Para los handlers HTTP, net/http/httptest te da un ResponseWriter falso para que puedas llamar al handler directamente, sin red:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer arranca un servidor real en localhost para probar código de cliente. La página del cliente HTTP lo usa en todos sus ejemplos.

Cobertura

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

La cobertura te dice qué líneas no se ejecutaron nunca, lo que sirve para encontrar ramas sin probar. Un número alto no te dice que las aserciones sean buenas: un test que llama a todas las funciones y no comprueba nada llega al 100%.

Benchmarks

Un benchmark es func BenchmarkXxx(b *testing.B). Go 1.24 añadió b.Loop, que ahora es la forma recomendada:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop ejecuta el cuerpo tantas veces como haga falta para una medida estable, excluye de la medición el código de preparación que va antes del bucle e impide que el compilador elimine la llamada por optimización. El código escrito antes de Go 1.24 usa for i := 0; i < b.N; i++, que sigue funcionando pero necesita b.ResetTimer() tras una preparación costosa y puede engañarse por la eliminación de código muerto.

Los benchmarks no se ejecutan con un go test normal. Pídelos, y sáltate los tests normales con -run='^$':

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

Las columnas son: el nombre con GOMAXPROCS añadido, las iteraciones ejecutadas, el tiempo por llamada, los bytes reservados por llamada y las reservas por llamada. Los números dependen de la máquina; compara ejecuciones en la misma. Para comparar de forma fiable el antes y el después de un cambio, ejecuta cada lado varias veces con -count=10 y pasa las dos salidas a benchstat (golang.org/x/perf/cmd/benchstat).

Los ejemplos también son tests

Una función de ejemplo imprime algo y declara la salida esperada en un comentario. go test la ejecuta y falla si la salida difiere, y go doc y pkg.go.dev la muestran como documentación:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

El nombre de paquete textutil_test hace de esto un test externo: solo puede usar la API exportada, como haría quien la llama de verdad. Esos archivos pueden estar en el mismo directorio que el paquete. Sin un comentario // Output:, el ejemplo se compila pero no se ejecuta. Usa // Unordered output: cuando las líneas puedan salir en cualquier orden.

Fuzzing, brevemente

Go 1.18 añadió los tests de fuzzing, que generan entradas para encontrar caídas e invariantes rotos:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

Un go test normal ejecuta solo las entradas semilla (los valores de f.Add y los archivos guardados bajo testdata/fuzz). go test -fuzz=FuzzSlugify sigue generando entradas nuevas hasta que encuentra un fallo o lo detienes, y guarda las entradas que fallan bajo testdata/fuzz para que se conviertan en casos de test normales.

Errores comunes

  • Archivo de test en el sitio equivocado o con un nombre incorrecto. Tiene que terminar en _test.go y estar en el directorio del paquete. slug_tests.go se compila dentro del paquete, no se ejecuta como test.
  • Minúscula después de Test. Testslugify no es un test. TestSlugify y Test_slugify sí.
  • t.Fatal desde otra goroutine. Informa allí con t.Error y espera a la goroutine antes de que retorne el test.
  • Mensajes sin la entrada. Incluye lo que se pasó, lo que salió y lo que se esperaba.
  • Fiarse de un resultado en caché. Usa -count=1 cuando un test dependa de algo externo al paquete.
  • Tests que dependen del orden de los demás o de variables globales compartidas. Cada test debería preparar su propio estado, que es para lo que están t.TempDir, t.Setenv y t.Cleanup.

Preguntas frecuentes

¿Cómo escribo un test unitario en Go?

Crea un archivo que termine en _test.go en el mismo directorio que el código, importa testing y escribe una función func TestName(t *testing.T) que llame a tu código e informe de las discrepancias con t.Errorf. Ejecuta go test en ese directorio, o go test ./... para todo el módulo. No hace falta ningún framework ni librería de aserciones.

¿Qué diferencia hay entre t.Error y t.Fatal en Go?

t.Error y t.Errorf marcan el test como fallido y siguen ejecutándolo, así que una sola ejecución puede informar de varios problemas. t.Fatal y t.Fatalf lo marcan como fallido y detienen el test de inmediato. Usa Fatal cuando no tenga sentido continuar, por ejemplo tras un error inesperado que deja el resultado inservible.

¿Cómo ejecuto un solo test en Go?

Pasa una expresión regular a -run: go test -run TestSlugify ejecuta todos los tests cuyo nombre coincide, y go test -run 'TestSlugifyTable/punctuation' ejecuta un subtest (los espacios en los nombres de subtest se convierten en guiones bajos). Añade -v para ver el nombre y el resultado de cada test, y -count=1 para saltarte la caché de tests.

¿Cómo escribo un benchmark en Go?

Escribe func BenchmarkName(b *testing.B) en un archivo _test.go y pon el código que quieres medir en un bucle for b.Loop() { ... } (Go 1.24; el código antiguo usa for i := 0; i < b.N; i++). Ejecútalo con go test -bench=. -benchmem, que informa de nanosegundos, bytes y reservas de memoria por operación.

¿Cómo veo la cobertura de los tests en Go?

go test -cover imprime el porcentaje de sentencias que ejecutaron los tests. Para más detalle, escribe un perfil con go test -coverprofile=cover.out y luego ejecuta go tool cover -func=cover.out para ver los números por función o go tool cover -html=cover.out para ver en un navegador las líneas cubiertas y sin cubrir.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR