Aller au contenu
Développement medium

Tester du code Go (table-driven)

12 min de lecture

Go

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.

  • Écrire un test avec le paquet testing et le lancer avec go test.
  • Structurer des cas en table-driven avec t.Run.
  • Mesurer la couverture avec go test -cover et -coverprofile.
  • Substituer une dépendance par une interface pour la tester isolément.

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.

account.go
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 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).

account_test.go
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 :

Fenêtre de terminal
go test

La sortie affiche ok suivi du paquet et de la durée si tout passe. Ajoutez -v pour voir chaque test individuellement.

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

account_test.go
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.

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.

Fenêtre de terminal
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)
PASS
ok example/account/account 0.001s

Les espaces des noms sont remplacés par des underscores dans le chemin. Pour ne rejouer que le cas « vide » :

Fenêtre de terminal
go test -run 'TestValidateUsername/vide'

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.

Fenêtre de terminal
go test -cover
ok example/account/account 0.003s coverage: 90.0% of statements

Pour savoir quelles lignes manquent, générez un profil puis détaillez-le par fonction :

Fenêtre de terminal
go test -coverprofile=cover.out
go tool cover -func=cover.out
account.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.

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.

account_test.go
// 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.

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

account_test.go
func BenchmarkValidateUsername(b *testing.B) {
for b.Loop() {
ValidateUsername("bob_42")
}
}
Fenêtre de terminal
go test -bench=. -run=^$ -benchmem
BenchmarkValidateUsername-4 346714802 3.412 ns/op 0 B/op 0 allocs/op

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

  • 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 %.
  • Le paquet testing et go test suffisent : pas de framework requis en Go.
  • Le table-driven est l'idiome dominant : une table de cas, une boucle, un t.Run par cas.
  • t.Run nomme les sous-tests et permet de rejouer un cas isolé avec -run.
  • go test -cover et go tool cover -func révèlent les angles morts.
  • Une interface remplace une dépendance réelle par un mock en mémoire, sans outil externe.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn