Blog

Paquetes, módulos y pruebas en Go

Un módulo de Go es un árbol de paquetes bajo un solo archivo go.mod. Aprende cómo funcionan las rutas de import, los nombres exportados y las carpetas internal, y luego prueba el código con tablas de pruebas, subtests, ejemplos y benchmarks.

Hasta ahora, cada programa de esta serie cabía en un solo archivo llamado main.go. El código real de Go no se queda tan pequeño. Lo divides en paquetes, los paquetes viven en un módulo, y el módulo trae pruebas que ejecuta go test.

Este post construye un módulo pequeño, un contador de palabras con una biblioteca, un paquete auxiliar privado, un comando y pruebas, y recorre cada archivo. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución. El código que se muestra sale directamente de los archivos del módulo, y esos archivos pasan go vet, go test y go test -race.

Un paquete es una carpeta, un módulo es un árbol de carpetas

Un paquete de Go son todos los archivos .go de una carpeta, y un módulo es un árbol de carpetas de paquetes con un archivo go.mod arriba de todo. Este es el módulo completo que usa este post:

11-wordcount/
├── go.mod
├── wordcount.go
├── wordcount_test.go
├── example_test.go
├── internal/
│   └── tokenize/
│       ├── tokenize.go
│       └── tokenize_test.go
└── cmd/
    └── wc/
        ├── main.go
        └── main_test.go

Son tres paquetes. Los archivos de arriba forman el paquete wordcount, la biblioteca. internal/tokenize divide el texto en palabras. cmd/wc es un programa pequeño que usa la biblioteca.

Cada archivo de una carpeta debe declarar el mismo nombre de paquete en su primera línea, y la carpeta contiene exactamente un paquete. La única excepción son los archivos de prueba, que aparecen más abajo.

El archivo go.mod tiene dos líneas:

module example.com/wordcount

go 1.26

La línea module es la ruta del módulo. Es el prefijo de cada ruta de import dentro del módulo. Agrega al final la carpeta de un paquete y tienes su ruta de import. go list las imprime:

$ go list ./...
example.com/wordcount
example.com/wordcount/cmd/wc
example.com/wordcount/internal/tokenize

./... significa “esta carpeta y todas las carpetas debajo de ella”. También lo vas a usar con go test, go vet y go build.

Entonces la ruta de import no es un nombre de archivo, y tampoco es una ruta en tu disco. Es la ruta del módulo unida a la ruta de la carpeta. El nombre del paquete, la palabra después de package, suele ser la última parte de esa ruta, y es lo que escribes antes del punto: tokenize.Words, wordcount.Count.

Los nombres exportados son la única forma de entrar

El código de un paquete de Go puede usar los nombres de otro paquete solo cuando empiezan con mayúscula. Ya viste esa regla con strings.ToUpper en la primera parte. Funciona igual con los paquetes que escribes tú. Esta es la biblioteca:

// Package wordcount counts words in text.
package wordcount

import (
	"cmp"
	"slices"
	"strings"

	"example.com/wordcount/internal/tokenize"
)

// Pair is one word and how many times it appeared.
type Pair struct {
	Word  string
	Count int
}

// Count returns how many times each word appears in text.
// Words are compared without regard to case.
func Count(text string) map[string]int {
	counts := map[string]int{}
	for _, w := range tokenize.Words(text) {
		counts[normalize(w)]++
	}
	return counts
}

// Top returns the n most frequent words, most frequent first.
// Words with the same count are sorted alphabetically.
func Top(counts map[string]int, n int) []Pair {
	pairs := make([]Pair, 0, len(counts))
	for w, c := range counts {
		pairs = append(pairs, Pair{Word: w, Count: c})
	}
	slices.SortFunc(pairs, func(a, b Pair) int {
		if c := cmp.Compare(b.Count, a.Count); c != 0 {
			return c
		}
		return cmp.Compare(a.Word, b.Word)
	})
	return pairs[:min(n, len(pairs))]
}

// normalize is unexported: only code in package wordcount can call it.
func normalize(word string) string {
	return strings.ToLower(word)
}

Pair, Count y Top empiezan con mayúscula, así que están exportados. También lo están los campos Word y Count. normalize empieza con minúscula, así que solo el código del paquete wordcount puede llamarla. Count la llama sin problema, porque las dos viven en el mismo paquete.

Los empates en Top se resuelven por orden alfabético a propósito. El orden de iteración de un map cambia de una ejecución a otra, y una función que devuelve resultados en orden aleatorio es difícil de probar.

Para ver cómo se aplica la regla, agregué una función de ejemplo a un archivo de prueba que está fuera del paquete y llama a wordcount.normalize("Go"). go test se negó a compilarla:

$ go test .
# example.com/wordcount_test [example.com/wordcount.test]
./example_test.go:27:24: undefined: wordcount.normalize
FAIL	example.com/wordcount [build failed]

Fíjate en lo que dice el mensaje. No dice “normalize es privado”. Dice undefined. Desde fuera del paquete, un nombre no exportado no existe.

Eso es lo que hace seguro cambiar un nombre no exportado. Puedes renombrar normalize, cambiar sus argumentos o borrarla, y ningún código fuera del paquete se puede romper, porque ninguno la puede alcanzar.

internal/: paquetes que solo este módulo puede importar

Una carpeta llamada internal hace que los paquetes que tiene debajo solo se puedan importar desde el código que cuelga de la carpeta que contiene a internal. Aquí internal está arriba de todo en el módulo, así que cada paquete del módulo puede importar tokenize, y nada de fuera puede:

// Package tokenize splits text into words.
package tokenize

import (
	"strings"
	"unicode"
)

// Words splits text into words. A word is a run of letters, digits and
// apostrophes. Apostrophes at either end of a word are dropped.
func Words(text string) []string {
	fields := strings.FieldsFunc(text, func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
	})
	words := fields[:0]
	for _, f := range fields {
		if w := strings.Trim(f, "'"); w != "" {
			words = append(words, w)
		}
	}
	return words
}

Words está exportada, así que el paquete wordcount puede llamar a tokenize.Words. Pero solo está exportada dentro del módulo.

Para comprobarlo, hice un segundo módulo, example.com/other, que importa los dos paquetes:

$ go build .
package example.com/other
	main.go:7:2: use of internal package example.com/wordcount/internal/tokenize not allowed

Importar example.com/wordcount funcionó sin problema. El comando go rechazó el import de internal/tokenize antes de que el compilador mirara una sola línea.

Eso te da un lugar para el código que quieres compartir entre tus propios paquetes sin prometérselo a nadie más. Si tokenize fuera un paquete normal, alguien podría importarlo, y cambiar Words rompería su código. Bajo internal, lo puedes cambiar cuando quieras.

Explicado como si tuvieras diez años

Piensa en un módulo como una casa, y en cada paquete como un cuarto de esa casa.

Las cosas de un cuarto que tienen nombre en minúscula se quedan en ese cuarto. Nadie de otro cuarto las puede tocar. Ni siquiera las puede ver.

Las cosas con mayúscula están junto a la puerta del cuarto, mirando al pasillo. Cualquiera que llegue a la puerta las puede usar. Eso es lo que significa exportado.

Los cuartos internal son los cuartos de la familia. Sus puertas también dan al pasillo, pero un letrero en la puerta de entrada dice “solo familia”. La gente que vive en esta casa puede entrar. A un visitante de otra casa lo detienen en la puerta de entrada.

La versión precisa

Un nombre declarado en el nivel superior de un paquete está exportado cuando su primer carácter es una letra mayúscula. Solo los nombres exportados se pueden usar desde otro paquete. Eso lo comprueba el compilador.

Una ruta de import que contiene un elemento internal solo la pueden importar los paquetes cuya ruta empieza con la parte anterior a internal. example.com/wordcount/internal/tokenize la pueden importar example.com/wordcount y todo lo que está debajo. Eso lo comprueba el comando go cuando resuelve los imports.

Las dos reglas se suman. Un nombre dentro de un paquete internal necesita una mayúscula y un importador permitido.

Dónde falla la analogía: los cuartos de una casa real están uno al lado del otro. La regla de Go sigue el árbol de carpetas. Una carpeta internal que está tres niveles abajo solo deja entrar a los paquetes de la carpeta de arriba, así que la “familia” puede ser una parte pequeña de la casa, no la casa entera.

Un comando que usa la biblioteca

Una carpeta cuyos archivos dicen package main compila un programa, y dentro de un módulo importa la biblioteca por su ruta de import como cualquier otro paquete. Poner los comandos en cmd/<name> es una organización común, no una regla:

// Command wc prints the most frequent words read from standard input.
package main

import (
	"fmt"
	"io"
	"log"
	"os"

	"example.com/wordcount"
)

func main() {
	if err := run(os.Stdin, os.Stdout, 3); err != nil {
		log.Fatal(err)
	}
}

// run reads all of r and writes the n most frequent words to w.
func run(r io.Reader, w io.Writer, n int) error {
	text, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	counts := wordcount.Count(string(text))
	for _, p := range wordcount.Top(counts, n) {
		fmt.Fprintf(w, "%-8s %d\n", p.Word, p.Count)
	}
	return nil
}

main casi no hace nada. El trabajo está en run, que recibe un io.Reader y un io.Writer en lugar de usar os.Stdin y os.Stdout directamente. Eso lo hace fácil de probar, como vas a ver más abajo.

Pásale la oración de la parte sobre maps:

$ printf 'the cat sat on the mat and the cat slept' | go run ./cmd/wc
the      3
cat      2
and      1

go run ./cmd/wc nombra la carpeta del paquete. No escribiste nada en go.mod para que el import funcione, porque example.com/wordcount está dentro del módulo en el que estás.

Código de otras personas: go get, go mod tidy y go.sum

Esta serie usa solo la biblioteca estándar, así que el módulo no tiene dependencias. Igual las vas a agregar en proyectos reales, y los pasos son cortos.

Agregas un import del paquete en tu código y luego ejecutas go mod tidy. Lee cada import del módulo, descarga los módulos que proveen los que faltan y escribe una línea require para cada uno en go.mod. También quita las líneas require que ya nadie importa. go get example.com/some/module@v1.2.3 hace la parte de agregar directamente, cuando quieres una versión en particular.

En este módulo, go mod tidy no imprime nada y no cambia nada, porque no hay nada que agregar ni quitar. Ejecútalo de todos modos antes de hacer commit. Es la forma más rápida de mantener go.mod al día.

La primera vez que un módulo gana una dependencia, aparece un segundo archivo: go.sum. Guarda un hash criptográfico de cada versión de módulo que usa la compilación. La próxima vez que alguien descargue esa versión, en cualquier computadora, el comando go la compara con el hash y se detiene si el contenido cambió. Haz commit de go.sum junto con go.mod, y no lo edites a mano.

Tu primera prueba

Una prueba de Go vive en un archivo cuyo nombre termina en _test.go, en la misma carpeta que el código que prueba. Una prueba es una función cuyo nombre empieza con Test y que recibe un *testing.T:

package tokenize

import (
	"slices"
	"testing"
)

func TestWords(t *testing.T) {
	got := Words("Don't panic -- 'quoted' words, 42 times!")
	want := []string{"Don't", "panic", "quoted", "words", "42", "times"}
	if !slices.Equal(got, want) {
		t.Errorf("Words() = %q, want %q", got, want)
	}
}

No hay biblioteca de aserciones. Comparas los valores tú mismo y llamas a t.Errorf cuando están mal. El mensaje sigue una costumbre de Go: di qué llamaste, qué obtuviste y qué querías.

go build ignora los archivos _test.go, así que las pruebas nunca terminan dentro de tu programa. go test las compila con el paquete y ejecuta cada función Test. go test ./... hace eso con cada paquete del módulo:

$ go test ./...
ok  	example.com/wordcount	0.005s
ok  	example.com/wordcount/cmd/wc	0.004s
ok  	example.com/wordcount/internal/tokenize	0.004s

La duración al final de cada línea cambia en cada ejecución. Ejecútalo otra vez sin cambiar nada, y cada línea dice (cached) en su lugar. go test recuerda un resultado exitoso hasta que cambia el código o la prueba. Agrega -count=1 cuando quieras que las pruebas se ejecuten de nuevo de todos modos.

Pruebas con tablas y subtests

Una prueba con tabla pone los casos en un slice de structs y aplica la misma comprobación a cada uno. Es la forma de prueba más común en el código Go:

package wordcount

import (
	"maps"
	"testing"
)

func TestCount(t *testing.T) {
	tests := []struct {
		name string
		text string
		want map[string]int
	}{
		{"empty", "", map[string]int{}},
		{"one word", "go", map[string]int{"go": 1}},
		{"mixed case", "Go go GO", map[string]int{"go": 3}},
		{"punctuation", "stop. Stop, stop!", map[string]int{"stop": 3}},
		{"apostrophes", "don't 'quote' me", map[string]int{"don't": 1, "quote": 1, "me": 1}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Count(tt.text)
			if !maps.Equal(got, tt.want) {
				t.Errorf("Count(%q) = %v, want %v", tt.text, got, tt.want)
			}
		})
	}
}

Agregar un caso es una línea en la tabla. t.Run ejecuta cada caso como un subtest con nombre, con su propio t. Un caso que falla reporta su nombre, y los demás casos se siguen ejecutando.

Este archivo dice package wordcount, igual que el código que prueba. Eso pone la prueba dentro del cuarto, así que también puede probar nombres no exportados. Este es TestNormalize completo:

func TestNormalize(t *testing.T) {
	if got := normalize("HeLLo"); got != "hello" {
		t.Errorf("normalize(%q) = %q, want %q", "HeLLo", got, "hello")
	}
}

-v lista cada prueba y subtest mientras se ejecuta:

$ go test -v -run TestCount .
=== RUN   TestCount
=== RUN   TestCount/empty
=== RUN   TestCount/one_word
=== RUN   TestCount/mixed_case
=== RUN   TestCount/punctuation
=== RUN   TestCount/apostrophes
--- PASS: TestCount (0.00s)
    --- PASS: TestCount/empty (0.00s)
    --- PASS: TestCount/one_word (0.00s)
    --- PASS: TestCount/mixed_case (0.00s)
    --- PASS: TestCount/punctuation (0.00s)
    --- PASS: TestCount/apostrophes (0.00s)
PASS

Después viene una línea final ok con una duración, como antes.

-run recibe una expresión regular y ejecuta solo las pruebas cuyo nombre coincide. Una barra en el patrón llega hasta los subtests. Los nombres de los casos tenían espacios, y Go los reemplazó con guiones bajos. Puedes escribir cualquiera de las dos formas. -run 'TestCount/mixed case' y -run TestCount/mixed_case eligen el mismo subtest, porque el patrón pasa por la misma conversión:

$ go test -v -run 'TestCount/mixed case' .
=== RUN   TestCount
=== RUN   TestCount/mixed_case
--- PASS: TestCount (0.00s)
    --- PASS: TestCount/mixed_case (0.00s)
PASS

t.Errorf, t.Fatalf y t.Helper

t.Errorf marca una prueba como fallida y sigue, mientras que t.Fatalf la marca como fallida y detiene esa prueba en el acto. La función auxiliar que comprueba los resultados de Top usa las dos:

// assertPairs fails the test if got and want differ.
func assertPairs(t *testing.T, got, want []Pair) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("got %d pairs, want %d: %v", len(got), len(want), got)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("pair %d = %v, want %v", i, got[i], want[i])
		}
	}
}

func TestTop(t *testing.T) {
	counts := map[string]int{"a": 1, "b": 3, "c": 3, "d": 2}

	assertPairs(t, Top(counts, 2), []Pair{{"b", 3}, {"c", 3}})
	assertPairs(t, Top(counts, 10), []Pair{{"b", 3}, {"c", 3}, {"d", 2}, {"a", 1}})
	assertPairs(t, Top(counts, 0), []Pair{})
}

Si las longitudes son distintas, el bucle de abajo accedería más allá del final de un slice y provocaría un panic. Por eso esa comprobación usa t.Fatalf. Si las longitudes coinciden, vale la pena ver cada par incorrecto, así que el bucle usa t.Errorf.

Para ver la diferencia, cambié lo esperado en la segunda llamada a tres pares, y en la tercera a un par. Solo se reportó una falla:

$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
    wordcount_test.go:53: got 4 pairs, want 3: [{b 3} {c 3} {d 2} {a 1}]
FAIL

t.Fatalf detuvo TestTop en la línea 53, así que la comprobación rota de la línea 54 nunca se ejecutó. t.Fatalf detiene la prueba o el subtest actual, no toda la ejecución. Las otras pruebas se siguen ejecutando.

El número de línea es obra de t.Helper(). Marca assertPairs como función auxiliar, así que las fallas reportan la línea que la llamó. Con las pruebas restauradas, invertí el orden esperado en la primera llamada, en la línea 52:

$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
    wordcount_test.go:52: pair 0 = {b 3}, want {c 3}
    wordcount_test.go:52: pair 1 = {c 3}, want {b 3}
FAIL

Después comenté t.Helper() y lo ejecuté otra vez:

$ go test -run TestTop .
--- FAIL: TestTop (0.00s)
    wordcount_test.go:44: pair 0 = {b 3}, want {c 3}
    wordcount_test.go:44: pair 1 = {c 3}, want {b 3}
FAIL

La línea 44 es el t.Errorf dentro de la función auxiliar. Es la misma para las tres llamadas, así que no te dice cuál llamada falló. Pon t.Helper() al principio de cada función auxiliar de prueba.

Probar el comando

Un package main también puede tener pruebas, y la función run lo hace fácil. La prueba le pasa un lector de string y recoge lo que escribe:

package main

import (
	"strings"
	"testing"
)

func TestRun(t *testing.T) {
	in := strings.NewReader("one two two three three three")
	var out strings.Builder
	if err := run(in, &out, 2); err != nil {
		t.Fatalf("run: %v", err)
	}
	want := "three    3\ntwo      2\n"
	if got := out.String(); got != want {
		t.Errorf("run wrote %q, want %q", got, want)
	}
}

strings.Builder es un io.Writer, así que puede ocupar el lugar de os.Stdout. Mantener main diminuto y pasar lectores y escritores de un lado a otro es la forma en que los programas Go siguen siendo fáciles de probar sin lanzar un proceso real.

Las funciones de ejemplo son documentación que se comprueba

Una función de ejemplo empieza con Example, no recibe argumentos y termina con un comentario // Output:. go test la ejecuta y compara lo que imprimió con ese comentario:

package wordcount_test

import (
	"fmt"

	"example.com/wordcount"
)

func ExampleCount() {
	counts := wordcount.Count("The cat saw the other cat.")
	fmt.Println(counts)
	// Output: map[cat:2 other:1 saw:1 the:2]
}

func ExampleTop() {
	counts := wordcount.Count("to be or not to be, that is the question")
	for _, p := range wordcount.Top(counts, 3) {
		fmt.Println(p.Word, p.Count)
	}
	// Output:
	// be 2
	// to 2
	// is 1
}

Este archivo dice package wordcount_test, con el sufijo _test. Es el único caso en que una carpeta puede tener dos nombres de paquete. El paquete _test se compila aparte e importa wordcount como lo haría cualquier código de fuera. Así que los ejemplos usan solo nombres exportados, exactamente como lo haría quien lee tu documentación. Este también es el archivo donde antes falló la compilación de la llamada a normalize.

Aquí fmt.Println sobre un map es seguro, porque fmt ordena las claves del map antes de imprimirlas.

Para ver la comprobación en acción, cambié la salida esperada de ExampleCount por algo incorrecto:

$ go test -run ExampleCount .
--- FAIL: ExampleCount (0.00s)
got:
map[cat:2 other:1 saw:1 the:2]
want:
map[cat:2 other:1 saw:1 The:1 the:1]
FAIL

Ese es el sentido de los ejemplos. go doc y pkg.go.dev muestran ExampleCount junto a Count como documentación de uso, y go test falla en el momento en que la documentación deja de ser cierta. Un ejemplo sin comentario // Output: se compila, pero no se ejecuta.

Benchmarks con b.Loop

Un benchmark es una función cuyo nombre empieza con Benchmark y que recibe un *testing.B. Está en el mismo archivo de prueba:

var sample = "the quick brown fox jumps over the lazy dog and the dog sleeps"

func BenchmarkCount(b *testing.B) {
	for b.Loop() {
		Count(sample)
	}
}

for b.Loop() ejecuta el cuerpo tantas veces como el paquete testing necesite para obtener una medición estable. Llegó en Go 1.24. El código más antiguo usa for i := 0; i < b.N; i++, que sigue funcionando. Ahora b.Loop es la mejor opción: deja fuera de la medición el código de preparación que va antes del bucle, y evita que el compilador elimine una llamada cuyo resultado ignoras.

go test no ejecuta benchmarks a menos que lo pidas:

$ go test -bench=. -run='^$' .

-bench=. ejecuta todos los benchmarks, y -run='^$' no coincide con ningún nombre de prueba, así que las pruebas normales se saltan. La salida empieza con líneas que nombran tu sistema operativo, la arquitectura de CPU, el paquete y el modelo de CPU. Luego hay una línea por benchmark: su nombre con la cantidad de CPUs como sufijo, como BenchmarkCount-12, cuántas veces se ejecutó el bucle y el tiempo promedio por ejecución en ns/op. Agrega -benchmem y la línea también muestra bytes y asignaciones por ejecución.

No copié los números, porque dependen de la computadora y cambian de una ejecución a otra. Compara resultados de benchmarks solo de la misma computadora, ejecutados varias veces.

go vet se ejecuta dentro de go test

go test ejecuta un conjunto de comprobaciones de go vet sobre el paquete antes de ejecutar cualquier prueba, y un problema de vet hace fallar la compilación. Para verlo, cambié TestNormalize para que pase un string a un verbo %d:

$ go test .
# example.com/wordcount
# [example.com/wordcount]
./wordcount_test.go:32:29: (*testing.common).Errorf format %d has arg got of wrong type string
FAIL	example.com/wordcount [build failed]

No se ejecutó ninguna prueba. El mismo error en un programa normal habría impreso un mensaje mal formado en tiempo de ejecución. En una prueba, te detiene antes de que se ejecute nada. go test ejecuta solo una parte de las comprobaciones de vet, las que casi nunca se equivocan, así que ejecuta también go vet ./... por tu cuenta.

go test -race ./... compila las pruebas con el detector de condiciones de carrera activado. Este módulo no tiene goroutines, así que pasa y tiene poco que encontrar. Se vuelve esencial en la parte sobre goroutines.

Qué recordar

  • Un paquete es una carpeta de archivos .go. Un módulo es un árbol de paquetes con un go.mod. Una ruta de import es la ruta del módulo más la ruta de la carpeta.
  • Una primera letra mayúscula exporta un nombre. Desde fuera del paquete, un nombre no exportado es undefined.
  • Los paquetes bajo internal/ solo se pueden importar desde dentro del árbol que contiene esa carpeta internal.
  • go mod tidy mantiene go.mod de acuerdo con tus imports. go.sum guarda los hashes de tus dependencias. Haz commit de los dos.
  • Las pruebas viven en archivos _test.go como func TestX(t *testing.T). Usa tablas y t.Run, t.Errorf para seguir, t.Fatalf para detenerte y t.Helper en las funciones auxiliares.
  • go test ./... ejecuta todo, -run elige pruebas, -v las lista, y las comprobaciones de vet van primero. Los ejemplos con // Output: se comprueban, y los benchmarks usan for b.Loop().

En Go, la carpeta decide el paquete, la primera letra decide quién puede ver un nombre, y go test comprueba las dos cosas.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.