Test piloté par les données en Go
1. Aperçu
Quand une fonction a plusieurs branches à gérer, vous voudrez tester plusieurs combinaisons d’entrées/sorties différentes. Si vous écrivez chaque cas comme une fonction de test séparée, le code de test se répétera beaucoup et sera difficile à maintenir.
Le test piloté par les données est la façon la plus commune d’écrire des tests dans la communauté Go. Vous décrivez les cas de test sous la forme d’une « table » de données, puis vous utilisez une seule boucle pour les exécuter tous.
Après avoir lu cet article, vous saurez comment écrire des tests pilotés par les données avec des sous-tests, comment vérifier les erreurs correctement, comment exécuter les tests en parallèle et comment comparer des structures complexes. Vous saurez aussi quand ne pas utiliser cette technique.
2. Fonction à tester
Nous utilisons une fonction qui calcule le prix après application d’un code de réduction pour une commande. Cette fonction a assez de branches pour bien illustrer le concept :
// pricing/discount.go
package pricing
import (
"errors"
"strings"
)
var (
ErrNegativeTotal = errors.New("le montant total ne doit pas être négatif")
ErrInvalidCode = errors.New("code de réduction invalide")
)
// ApplyDiscount retourne le montant (en dong) après l'application du code de réduction.
func ApplyDiscount(total int, code string) (int, error) {
if total < 0 {
return 0, ErrNegativeTotal
}
switch strings.ToUpper(strings.TrimSpace(code)) {
case "":
return total, nil
case "SALE10":
return total * 90 / 100, nil
case "GIAM50K":
if total < 50000 {
return 0, nil
}
return total - 50000, nil
default:
return 0, ErrInvalidCode
}
}
La fonction a cinq comportements à tester : montant négatif, pas de code, code SALE10, code GIAM50K (deux branches) et code inexistant. De plus, le code est normalisé avec TrimSpace et ToUpper.
3. Structure de base du test piloté par les données
3.1. Première version
Un test piloté par les données comporte trois parties : déclaration du tableau, itération sur le tableau et comparaison des résultats.
// pricing/discount_test.go
package pricing
import "testing"
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "pas_de_code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10_minuscules_et_espaces", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k_normal", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k_montant_insuffisant", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("%s: erreur inattendue: %v", tc.name, err)
}
if got != tc.want {
t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
tc.name, tc.total, tc.code, got, tc.want)
}
}
}
Explication :
testsest une slice d’une struct anonyme. Chaque élément est un cas de test, comprenant l’entrée (total,code) et la sortie attendue (want).- Le champ
namevous aide à identifier quel cas a échoué. - La boucle
forappelle la fonction et compare le résultat. Les messages d’erreur suivant le modèleF(entrée) = obtenu, want Xsont une convention bien connue en Go.
Pour ajouter un nouveau cas, vous n’avez qu’à ajouter une ligne au tableau. C’est le plus grand avantage de cette technique.
3.2. Problèmes de la première version
Le code ci-dessus fonctionne mais a deux inconvénients :
t.Fatalfarrête tout le test. Un cas qui échoue empêchera les cas suivants de s’exécuter.- Vous ne pouvez pas exécuter séparément un cas lors du débogage.
Les sous-tests résolvent ces deux problèmes.
4. Utiliser les sous-tests avec t.Run
t.Run crée un sous-test pour chaque cas. Chaque sous-test a son propre *testing.T, donc t.Fatal n’arrête que le cas actuel.
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "pas_de_code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10_minuscules_et_espaces", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k_normal", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k_montant_insuffisant", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if got != tc.want {
t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
tc.total, tc.code, got, tc.want)
}
})
}
}
Vous n’avez plus besoin d’imprimer tc.name dans le message d’erreur, car Go attache le nom du sous-test à la sortie. Exécutez avec -v :
go test ./pricing -v
=== RUN TestApplyDiscount
=== RUN TestApplyDiscount/pas_de_code
=== RUN TestApplyDiscount/sale10
=== RUN TestApplyDiscount/sale10_minuscules_et_espaces
=== RUN TestApplyDiscount/giam50k_normal
=== RUN TestApplyDiscount/giam50k_montant_insuffisant
--- PASS: TestApplyDiscount (0.00s)
--- PASS: TestApplyDiscount/pas_de_code (0.00s)
--- PASS: TestApplyDiscount/sale10 (0.00s)
--- PASS: TestApplyDiscount/sale10_minuscules_et_espaces (0.00s)
--- PASS: TestApplyDiscount/giam50k_normal (0.00s)
--- PASS: TestApplyDiscount/giam50k_montant_insuffisant (0.00s)
PASS
Vous remarquez que les espaces dans les noms ont été remplacés par des tirets bas _. Pour exécuter séparément un cas, vous passez une regex à -run :
go test ./pricing -run 'TestApplyDiscount/giam50k' -v
Cette commande n’exécute que les deux cas dont le nom commence par giam50k. Quand un cas échoue, la sortie ressemble à ceci :
--- FAIL: TestApplyDiscount (0.00s)
--- FAIL: TestApplyDiscount/sale10 (0.00s)
discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000
Vous savez immédiatement quel cas est cassé, à quelle ligne, avec quelle entrée.
Note : Donnez aux cas des noms courts, uniques et faciles à taper à nouveau sur le terminal. Si deux cas ont le même nom, Go ajoutera automatiquement un suffixe
#01, ce qui rend difficile l’exécution isolée d’un cas.
5. Vérifier les erreurs dans le tableau de test
Les cas qui retournent une erreur doivent également être dans le même tableau. Vous ajoutez un champ wantErr de type error, puis vous comparez avec errors.Is :
package pricing
import (
"errors"
"testing"
)
func TestApplyDiscount_Errors(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
wantErr error
}{
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "montant_negatif", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
{name: "code_inexistant", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr != nil {
return // erreur vérifiée, pas besoin de vérifier la valeur de retour
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
Explication :
- Pour un cas réussi,
wantErrporte la valeur nullenil. Dans ce cas,errors.Is(nil, nil)retournetrue. - Si la fonction retourne une erreur alors que vous ne l’attendez pas,
errors.Is(err, nil)retournefalseet le test échoue immédiatement. errors.Isfonctionne toujours quand l’erreur est encapsulée avecfmt.Errorf("...: %w", err). La comparaisonerr == tc.wantErrne le fait pas.
De nombreuses bases de code utilisent wantErr bool par simplicité. Cette approche indique seulement s’il y a une erreur ou non, mais pas quelle erreur. Vous devriez préférer comparer avec une erreur sentinelle si le package en définit une.
6. Exécuter en parallèle avec t.Parallel
Quand les cas de test sont lents (appels I/O, traitement lourd), vous pouvez faire exécuter les sous-tests en parallèle :
func TestApplyDiscount_Parallel(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "pas_de_code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
t.Parallel() indique au test runner que ce sous-test peut s’exécuter en même temps que d’autres sous-tests en parallèle. La fonction parent met en pause temporairement les sous-tests, finit la boucle, puis libère tous les sous-tests pour s’exécuter simultanément.
À partir de Go 1.22, chaque itération de la boucle for crée une nouvelle variable tc, donc la closure capture toujours la bonne valeur. Avec Go 1.21 et antérieur, vous devez copier la variable :
for _, tc := range tests {
tc := tc // nécessaire pour Go < 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
Si vous omettez la ligne tc := tc sur les anciennes versions, tous les sous-tests utiliseront le dernier cas. Le test va toujours « passer » mais teste en réalité un seul cas. C’est une erreur très difficile à détecter. Vérifiez la ligne go dans votre go.mod.
Note : N’utilisez
t.Parallel()que quand les cas ne partagent pas d’état mutable, comme des variables globales, des fichiers temporaires partagés ou une même table de base de données. Avec une fonction pure commeApplyDiscount, le bénéfice est presque nul. Ne l’ajoutez pas par habitude.
7. Comparer des structures complexes avec go-cmp
Quand une fonction retourne une structure imbriquée, l’opérateur != ne fonctionne pas pour les structures contenant des slices ou des maps. reflect.DeepEqual ne retourne que true/false sans indiquer où c’est différent. La bibliothèque go-cmp résout ce problème. Vous l’ajoutez à votre module :
go get github.com/google/go-cmp/cmp
// go.mod (extrait)
require github.com/google/go-cmp v0.6.0
Supposons que vous ayez une fonction qui crée une facture :
// pricing/invoice.go
package pricing
type Invoice struct {
Customer string
Items []string
Total int
}
func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
final, err := ApplyDiscount(total, code)
if err != nil {
return Invoice{}, err
}
return Invoice{Customer: customer, Items: items, Total: final}, nil
}
Le test utilise cmp.Diff :
package pricing
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestBuildInvoice(t *testing.T) {
tests := []struct {
name string
customer string
items []string
total int
code string
want Invoice
}{
{
name: "avec_code_sale10",
customer: "An",
items: []string{"ao", "mu"},
total: 200000,
code: "SALE10",
want: Invoice{Customer: "An", Items: []string{"ao", "mu"}, Total: 180000},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
if err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("BuildInvoice() divergence (-want +got) :\n%s", diff)
}
})
}
}
cmp.Diff retourne une chaîne vide quand les deux valeurs sont égales. S’ils sont différents, vous obtenez un diff lisible :
BuildInvoice() divergence (-want +got) :
pricing.Invoice{
Customer: "An",
Items: {"ao", "mu"},
- Total: 180000,
+ Total: 200000,
}
Le signe - est la valeur attendue, le signe + est la valeur réelle. La convention (-want +got) doit être clairement indiquée dans le message pour que le lecteur n’ait pas à deviner.
8. Erreurs courantes et quand ne pas utiliser cette technique
Quelques erreurs courantes lors de l’écriture de tests pilotés par les données :
- Un tableau qui grossit trop avec trop de champs de drapeau. Quand la struct de test a des champs comme
setupMock bool,skipValidation boolouexpectCall int, et que le corps de la boucle est rempli deif, vous écrivez de la logique dans votre test. Divisez-le en fonctions de test séparées. - Utiliser
t.Fatalen dehors det.Run. Un cas qui échoue perdra les résultats de tous les cas qui le suivent. - Utiliser une map à la place d’une slice sans comprendre les conséquences.
map[string]struct{...}empêche les doublons de noms de cas et l’ordre d’exécution est aléatoire, ce qui peut révéler les dépendances cachées entre les cas. Cependant, la sortie ne sera pas dans un ordre fixe. Les deux façons sont acceptables, tant que l’équipe est d’accord. - Les messages d’erreur manquent d’entrée. Écrire simplement
t.Errorf("sai")force le débogueur à ouvrir le code de test. Imprimez toujours l’entrée, ce que vous avez obtenu et ce que vous attendiez. - Oubli des cas limites. Le tableau vous aide à voir facilement quels cas manquent. Revérifiez les valeurs 0, les nombres négatifs, les chaînes vides et les valeurs justes au seuil (par exemple
total: 50000avecGIAM50K).
Vous ne devriez pas utiliser de tests pilotés par les données quand :
- Chaque cas nécessite une configuration très différente, par exemple des mocks retournant des données différentes ou un ordre d’appel différent. Les forcer dans un tableau rend simplement le test plus difficile à lire.
- Vous n’avez qu’un ou deux cas. Une fonction de test directe sera plus facile à comprendre.
- Vous avez besoin d’explorer des entrées aléatoires. Dans ce cas, envisagez le fuzz testing (
func FuzzXxx(f *testing.F)), disponible depuis Go 1.18. Vous pouvez utiliser votre tableau de test comme corpus de départ viaf.Add.
9. Conclusion
Les tests pilotés par les données vous aident à regrouper plusieurs cas de test dans un tableau de données et à les exécuter avec une seule boucle. Combinés avec t.Run, chaque cas devient un sous-test indépendant. Vous pouvez exécuter chaque cas séparément avec -run, et un cas qui échoue n’arrête pas les autres.
Points à retenir :
- Chaque cas doit avoir un
name, une entrée et une sortie attendue. - Vérifiez les erreurs avec
errors.Isen utilisant un champwantErr error. - N’utilisez
t.Parallel()que si vous en avez vraiment besoin, et soyez attentif à la sémantique des variables de boucle avant Go 1.22. - Utilisez
cmp.Difflors de la comparaison de structures complexes. - Quand le tableau commence à contenir une logique de contrôle, c’est un signe que vous devriez diviser vos tests.