
Go embarque tout le nécessaire pour tester dans sa bibliothèque standard : le paquet testing et la commande go test, sans dépendance externe. Ce guide montre l'idiome dominant de l'écosystème, le test table-driven (une table de cas parcourue par une boucle), puis les sous-tests t.Run, la mesure de couverture et le remplacement d'une dépendance par une interface pour la mocker. Public visé : intermédiaire en Go. Tous les exemples sont testés avec Go 1.26.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire un test avec le paquet
testinget le lancer avecgo test. - Structurer des cas en table-driven avec
t.Run. - Mesurer la couverture avec
go test -coveret-coverprofile. - Substituer une dépendance par une interface pour la tester isolément.
Le code à tester
Section intitulée « Le code à tester »Prenons un cas concret : valider un nom d'utilisateur, puis l'enregistrer. Le fichier account.go expose une fonction de validation qui renvoie des erreurs sentinelles, et une fonction Register qui dépend d'un stockage via une interface UserStore.
package account
import ( "errors" "fmt")
var ( ErrEmpty = errors.New("le nom est vide") ErrTooShort = errors.New("le nom est trop court (min 3)") ErrTooLong = errors.New("le nom est trop long (max 20)") ErrBadChar = errors.New("caractère interdit (a-z, 0-9, _)") ErrLeadingDigit = errors.New("le nom ne peut pas commencer par un chiffre") ErrTaken = errors.New("le nom est déjà pris"))
func ValidateUsername(name string) error { if name == "" { return ErrEmpty } if len(name) < 3 { return ErrTooShort } if len(name) > 20 { return ErrTooLong } if name[0] >= '0' && name[0] <= '9' { return ErrLeadingDigit } for _, r := range name { switch { case r >= 'a' && r <= 'z': case r >= '0' && r <= '9': case r == '_': default: return ErrBadChar } } return nil}
// UserStore abstrait le stockage : on ne dépend pas d'une base concrète.type UserStore interface { Exists(name string) (bool, error)}
func Register(store UserStore, name string) error { if err := ValidateUsername(name); err != nil { return fmt.Errorf("nom invalide : %w", err) } taken, err := store.Exists(name) if err != nil { return fmt.Errorf("vérification du store : %w", err) } if taken { return ErrTaken } return nil}Un premier test
Section intitulée « Un premier test »Un test Go vit dans un fichier suffixé _test.go, dans le même paquet que le code testé. Chaque test est une fonction TestXxx qui reçoit un *testing.T. On signale un échec avec t.Errorf (le test continue) ou t.Fatalf (le test s'arrête).
package account
import "testing"
func TestValidateUsernameSimple(t *testing.T) { if err := ValidateUsername("alice"); err != nil { t.Errorf("alice devrait être valide, obtenu %v", err) }}Lancez les tests du paquet courant :
go testLa sortie affiche ok suivi du paquet et de la durée si tout passe. Ajoutez -v pour voir chaque test individuellement.
Le style table-driven
Section intitulée « Le style table-driven »Écrire une fonction TestXxx par cas devient vite répétitif. L'idiome Go consiste à lister les cas dans une table (une slice de structs) puis à les parcourir. Ajouter un cas revient à ajouter une ligne.
package account
import ( "errors" "testing")
func TestValidateUsername(t *testing.T) { cases := []struct { name string // nom du sous-test input string wantErr error // erreur sentinelle attendue, nil si valide }{ {"valide simple", "alice", nil}, {"valide avec underscore et chiffre", "bob_42", nil}, {"vide", "", ErrEmpty}, {"trop court", "ab", ErrTooShort}, {"commence par un chiffre", "1alice", ErrLeadingDigit}, {"caractère interdit", "al ice", ErrBadChar}, }
for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := ValidateUsername(tc.input) if !errors.Is(err, tc.wantErr) { t.Errorf("ValidateUsername(%q) = %v ; attendu %v", tc.input, err, tc.wantErr) } }) }}Deux points clés. On compare les erreurs avec errors.Is, pas avec == : cela suit la chaîne d'erreurs enveloppées avec %w. Et chaque cas tourne dans un sous-test nommé grâce à t.Run.
Les sous-tests t.Run
Section intitulée « Les sous-tests t.Run »t.Run(nom, func) crée un sous-test identifiable. Son intérêt est double : la sortie -v nomme précisément le cas qui échoue, et vous pouvez rejouer un seul cas avec -run.
go test -v=== RUN TestValidateUsername=== RUN TestValidateUsername/valide_simple=== RUN TestValidateUsername/vide=== RUN TestValidateUsername/trop_court--- PASS: TestValidateUsername (0.00s) --- PASS: TestValidateUsername/valide_simple (0.00s) --- PASS: TestValidateUsername/vide (0.00s) --- PASS: TestValidateUsername/trop_court (0.00s)PASSok example/account/account 0.001sLes espaces des noms sont remplacés par des underscores dans le chemin. Pour ne rejouer que le cas « vide » :
go test -run 'TestValidateUsername/vide'Mesurer la couverture
Section intitulée « Mesurer la couverture »go test -cover indique le pourcentage d'instructions exécutées par les tests. C'est un indicateur, pas un objectif absolu : 100 % de couverture ne garantit pas l'absence de bug, mais une zone à 0 % signale un angle mort.
go test -coverok example/account/account 0.003s coverage: 90.0% of statementsPour savoir quelles lignes manquent, générez un profil puis détaillez-le par fonction :
go test -coverprofile=cover.outgo tool cover -func=cover.outaccount.go:21: ValidateUsername 91.7%account.go:52: Register 87.5%total: (statements) 90.0%La commande go tool cover -html=cover.out ouvre un rapport visuel où les lignes non couvertes apparaissent en rouge, utile pour cibler les cas manquants.
Remplacer une dépendance par une interface
Section intitulée « Remplacer une dépendance par une interface »Register dépend d'un UserStore. En test, on ne veut ni base de données ni réseau : on fournit une implémentation en mémoire de l'interface. C'est le mock idiomatique de Go, sans framework.
// fakeStore implémente UserStore en mémoire.type fakeStore struct { existing map[string]bool failWith error}
func (f fakeStore) Exists(name string) (bool, error) { if f.failWith != nil { return false, f.failWith } return f.existing[name], nil}
func TestRegister(t *testing.T) { t.Run("nom libre", func(t *testing.T) { store := fakeStore{existing: map[string]bool{}} if err := Register(store, "carol"); err != nil { t.Fatalf("attendu nil, obtenu %v", err) } })
t.Run("nom déjà pris", func(t *testing.T) { store := fakeStore{existing: map[string]bool{"dave": true}} if err := Register(store, "dave"); !errors.Is(err, ErrTaken) { t.Fatalf("attendu ErrTaken, obtenu %v", err) } })
t.Run("nom invalide court-circuite le store", func(t *testing.T) { store := fakeStore{failWith: errors.New("ne devrait pas être appelé")} if err := Register(store, "x"); !errors.Is(err, ErrTooShort) { t.Fatalf("attendu ErrTooShort, obtenu %v", err) } })}Le troisième cas est instructif : le store est configuré pour échouer s'il est appelé. Comme Register valide le nom avant de toucher au store, l'erreur attendue est ErrTooShort, ce qui prouve que la validation court-circuite bien l'accès au stockage.
Les benchmarks en survol
Section intitulée « Les benchmarks en survol »Le paquet testing mesure aussi les performances. Une fonction BenchmarkXxx reçoit un *testing.B et exécute le code dans une boucle b.Loop() (l'idiome depuis Go 1.24).
func BenchmarkValidateUsername(b *testing.B) { for b.Loop() { ValidateUsername("bob_42") }}go test -bench=. -run=^$ -benchmemBenchmarkValidateUsername-4 346714802 3.412 ns/op 0 B/op 0 allocs/opLe flag -run=^$ désactive les tests classiques pour ne garder que les benchmarks ; -benchmem ajoute les colonnes allocations mémoire. Ici, 0 allocation par appel : la validation ne touche pas au tas.
Bonnes pratiques
Section intitulée « Bonnes pratiques »- Nommez chaque cas de la table : un échec pointe alors directement le scénario fautif.
- Comparez les erreurs avec
errors.Is, jamais avec==, pour survivre à l'enveloppement%w. - Testez le chemin d'erreur autant que le chemin nominal : c'est là que se cachent les régressions.
- Dépendez d'interfaces pour isoler la logique du réseau et de la base.
- Traitez la couverture comme un radar, pas comme une note : visez les zones critiques, pas le 100 %.
À retenir
Section intitulée « À retenir »- Le paquet
testingetgo testsuffisent : pas de framework requis en Go. - Le table-driven est l'idiome dominant : une table de cas, une boucle, un
t.Runpar cas. t.Runnomme les sous-tests et permet de rejouer un cas isolé avec-run.go test -coveretgo tool cover -funcrévèlent les angles morts.- Une interface remplace une dépendance réelle par un mock en mémoire, sans outil externe.