Menu

Tests en Golang : tests unitaires, tests par tableau et benchmarks

Comment tester du code Go avec le package standard testing et go test : fichiers _test.go, fonctions TestXxx, t.Errorf ou t.Fatalf, tests pilotés par tableau avec t.Run, helpers et répertoires temporaires, couverture, benchmarks avec b.Loop, et tests d'exemple.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

Le code testé

Les outils de test de Go font partie de la chaîne d'outils standard : le package testing et la commande go test. Aucun framework à installer, aucune bibliothèque d'assertions nécessaire.

Les exemples de cette page testent une petite fonction, Slugify, qui transforme un titre en slug d'URL. La voici, exécutable, avec une vérification faite à la main dans main :

L'éditeur du navigateur exécute main ; il ne peut pas lancer go test. Tout ce qui suit est écrit tel que cela vit dans un vrai projet, avec la sortie du terminal affichée après chaque commande. Tout a été exécuté avec Go 1.24.

Le premier test

Dans un projet, la fonction vit dans un package, et ses tests vivent à côté, dans un fichier dont le nom se termine par _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)
	}
}

Les règles qu'utilise go test :

  • Seuls les fichiers qui se terminent par _test.go sont des fichiers de test. go build les ignore, donc le code de test ne se retrouve jamais dans votre binaire.
  • Un test est une fonction nommée TestXxx (la partie après Test ne doit pas commencer par une minuscule) qui prend un seul *testing.T.
  • Un test réussit sauf s'il appelle l'une des méthodes d'échec ou 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

Le format de message d'échec Func(input) = got, want expected est la convention Go. Mettez l'entrée dans le message : un échec qui dit seulement got "a", want "b" vous renvoie au code pour savoir de quelle entrée il s'agissait.

t.Errorf ou t.Fatalf

MéthodeMarque l'échecArrête le test
t.Error, t.Errorfouinon, continue
t.Fatal, t.Fatalfouioui, immédiatement
t.Log, t.Logfnonnon, n'affiche qu'avec -v ou en cas d'échec
t.Skip, t.Skipfnonoui, signalé comme ignoré

Utilisez Errorf par défaut, pour qu'une exécution signale chaque champ faux. Utilisez Fatalf quand le reste du test ne peut pas fonctionner, typiquement après une erreur inattendue :

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 arrête le test en appelant runtime.Goexit, donc il doit être appelé depuis la goroutine du test elle-même. Depuis une goroutine que vous avez lancée, signalez avec t.Error et retournez.

Tests pilotés par tableau avec t.Run

La plupart des tests Go sont des tableaux : une slice de cas, une boucle, et t.Run pour donner à chaque cas son propre nom.

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)
			}
		})
	}
}

Ajouter un cas tient en une ligne. Chaque sous-test est signalé séparément, un Fatal dans l'un d'eux ne termine que ce sous-test, et vous pouvez en lancer un seul par son nom. Lancez tout avec -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

Les espaces dans les noms de sous-tests deviennent des underscores. Quand un cas échoue, la sortie nomme le sous-test et la ligne :

--- 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

Depuis Go 1.22, chaque itération de boucle a son propre tt, donc les closures dans t.Run voient le bon cas même quand les sous-tests s'exécutent en parallèle. Le code plus ancien contient souvent tt := tt en haut du corps de la boucle pour cette raison ; ce n'est plus nécessaire.

Pour exécuter des sous-tests en parallèle, appelez t.Parallel() au début de la fonction du sous-test. Ne le faites que lorsque les cas sont indépendants et assez lents pour que cela compte.

Les flags de go test que vous utiliserez

CommandeEffet
go test -vaffiche le nom et le résultat de chaque test ainsi que la sortie de t.Log
go test -run TestSlugifylance les tests dont le nom correspond à l'expression régulière
go test -run 'TestSlugifyTable/empty'lance un seul sous-test
go test -count=1ignore les résultats en cache et relance
go test -racelance avec le détecteur de data races
go test -shortdemande aux tests longs de s'ignorer eux-mêmes (if testing.Short() { t.Skip() })
go test -failfasts'arrête après le premier test en échec
go test -timeout 30séchoue si l'exécution dure plus longtemps (10 minutes par défaut)
go test -coveraffiche la couverture des instructions
go test -bench=.lance aussi les benchmarks

Quand vous nommez des packages (go test ., go test ./...), go test met en cache les résultats des packages dont le code et les entrées n'ont pas changé et affiche (cached) à côté. Un simple go test sans argument ne met jamais en cache. Le cache suit les fichiers et les variables d'environnement qu'un test lit via le package os, mais pas un état extérieur comme une base de données ou un service réseau, donc un test qui en dépend peut réussir depuis le cache alors qu'il échouerait maintenant ; -count=1 force une vraie exécution.

Helpers, répertoires temporaires et nettoyage

L'appel à writeFile dans TestCountWords ci-dessus est 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() marque la fonction comme helper, pour qu'un échec soit signalé à la ligne du test qui l'a appelée, pas dans le helper.
  • t.TempDir() crée un répertoire neuf et le supprime à la fin du test. Les tests n'ont jamais besoin de nettoyer eux-mêmes leurs fichiers.
  • t.Cleanup(func() { ... }) enregistre tout autre démontage (fermer un serveur, supprimer une table). Les nettoyages s'exécutent après le test et ses sous-tests, le dernier enregistré en premier.
  • t.Setenv("KEY", "value") définit une variable d'environnement pour ce test uniquement et la restaure ensuite.
  • t.Context() (Go 1.24) renvoie un context annulé juste avant l'exécution des nettoyages.

Les fichiers de fixtures vont dans un répertoire nommé testdata à côté des tests. L'outil go l'ignore en tant que package, et les tests s'exécutent avec le répertoire du package comme répertoire de travail, donc os.ReadFile("testdata/input.json") fonctionne.

Tester les erreurs et les handlers HTTP

Vérifiez les erreurs avec errors.Is ou errors.As, comme le ferait le code appelant :

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)
	}
}

Comparer des chaînes d'erreur casse dès que quelqu'un reformule un message.

Pour les handlers HTTP, net/http/httptest vous donne un faux ResponseWriter pour appeler le handler directement, sans réseau :

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 démarre un vrai serveur sur localhost pour tester du code client. La page sur le client HTTP l'utilise pour chaque exemple.

Couverture

$ 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 couverture vous dit quelles lignes ne se sont jamais exécutées, ce qui est utile pour trouver des branches non testées. Un chiffre élevé ne dit pas si les assertions valent quelque chose : un test qui appelle chaque fonction et ne vérifie rien atteint 100 %.

Benchmarks

Un benchmark est une fonction func BenchmarkXxx(b *testing.B). Go 1.24 a ajouté b.Loop, qui est désormais la forme recommandée :

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

b.Loop exécute le corps autant de fois que nécessaire pour une mesure stable, exclut du chronométrage le code de préparation situé avant la boucle, et empêche le compilateur d'éliminer l'appel par optimisation. Le code écrit avant Go 1.24 utilise for i := 0; i < b.N; i++, qui fonctionne toujours mais demande b.ResetTimer() après une préparation coûteuse et peut être trompé par l'élimination de code mort.

Les benchmarks ne s'exécutent pas avec un simple go test. Demandez-les, et sautez les tests ordinaires avec -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

Les colonnes sont : le nom suivi de GOMAXPROCS, le nombre d'itérations exécutées, le temps par appel, les octets alloués par appel, les allocations par appel. Les chiffres dépendent de la machine ; comparez des exécutions sur la même. Pour comparer de façon fiable avant et après une modification, lancez chaque côté plusieurs fois avec -count=10 et donnez les deux sorties à benchstat (golang.org/x/perf/cmd/benchstat).

Les exemples sont aussi des tests

Une fonction d'exemple affiche quelque chose et déclare la sortie attendue dans un commentaire. go test l'exécute et échoue si la sortie diffère, et go doc et pkg.go.dev l'affichent comme documentation :

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

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

Le nom de package textutil_test en fait un test externe : il ne peut utiliser que l'API exportée, comme un vrai appelant. De tels fichiers peuvent se trouver dans le même répertoire que le package. Sans commentaire // Output:, l'exemple est compilé mais pas exécuté. Utilisez // Unordered output: quand les lignes peuvent apparaître dans n'importe quel ordre.

Le fuzzing, en bref

Go 1.18 a ajouté les tests de fuzzing, qui génèrent des entrées pour trouver des plantages et des invariants cassés :

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 simple go test n'exécute que les entrées de départ (les valeurs de f.Add et les fichiers enregistrés sous testdata/fuzz). go test -fuzz=FuzzSlugify continue d'en générer de nouvelles jusqu'à trouver un échec ou jusqu'à ce que vous l'arrêtiez, et enregistre les entrées en échec sous testdata/fuzz pour qu'elles deviennent des cas de test ordinaires.

Erreurs courantes

  • Un fichier de test mal placé ou mal nommé. Il doit se terminer par _test.go et se trouver dans le répertoire du package. slug_tests.go est compilé dans le package, pas exécuté comme test.
  • Une minuscule après Test. Testslugify n'est pas un test. TestSlugify et Test_slugify en sont.
  • t.Fatal depuis une autre goroutine. Signalez avec t.Error à cet endroit, et attendez la goroutine avant que le test ne retourne.
  • Des messages sans l'entrée. Indiquez ce qui a été passé, ce qui est sorti, et ce qui était attendu.
  • Se fier à une réussite en cache. Utilisez -count=1 quand un test dépend de quoi que ce soit en dehors du package.
  • Des tests qui dépendent de l'ordre des autres ou de variables globales partagées. Chaque test doit préparer son propre état, c'est à cela que servent t.TempDir, t.Setenv et t.Cleanup.

Questions fréquentes

Comment écrire un test unitaire en Go ?

Créez un fichier qui se termine par _test.go dans le même répertoire que le code, importez testing, et écrivez une fonction func TestName(t *testing.T) qui appelle votre code et signale les écarts avec t.Errorf. Lancez go test dans ce répertoire, ou go test ./... pour tout le module. Aucun framework ni bibliothèque d'assertions n'est nécessaire.

Quelle est la différence entre t.Error et t.Fatal en Go ?

t.Error et t.Errorf marquent le test comme échoué et continuent son exécution, donc une exécution peut signaler plusieurs problèmes. t.Fatal et t.Fatalf le marquent comme échoué et arrêtent le test immédiatement. Utilisez Fatal quand continuer n'a pas de sens, par exemple après une erreur inattendue qui rend le résultat inutilisable.

Comment lancer un seul test en Go ?

Passez une expression régulière à -run : go test -run TestSlugify lance tous les tests dont le nom correspond, et go test -run 'TestSlugifyTable/punctuation' lance un seul sous-test (les espaces dans les noms de sous-tests deviennent des underscores). Ajoutez -v pour voir le nom et le résultat de chaque test, et -count=1 pour contourner le cache des tests.

Comment écrire un benchmark en Go ?

Écrivez func BenchmarkName(b *testing.B) dans un fichier _test.go et placez le code à mesurer dans une boucle for b.Loop() { ... } (Go 1.24 ; le code plus ancien utilise for i := 0; i < b.N; i++). Lancez-le avec go test -bench=. -benchmem, qui indique les nanosecondes, les octets et les allocations par opération.

Comment voir la couverture des tests en Go ?

go test -cover affiche le pourcentage d'instructions exécutées par les tests. Pour le détail, écrivez un profil avec go test -coverprofile=cover.out, puis lancez go tool cover -func=cover.out pour les chiffres par fonction ou go tool cover -html=cover.out pour voir les lignes couvertes et non couvertes dans un navigateur.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER