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 :

  • tests est 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 name vous aide à identifier quel cas a échoué.
  • La boucle for appelle la fonction et compare le résultat. Les messages d’erreur suivant le modèle F(entrée) = obtenu, want X sont 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 :

  1. t.Fatalf arrête tout le test. Un cas qui échoue empêchera les cas suivants de s’exécuter.
  2. 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, wantErr porte la valeur nulle nil. Dans ce cas, errors.Is(nil, nil) retourne true.
  • Si la fonction retourne une erreur alors que vous ne l’attendez pas, errors.Is(err, nil) retourne false et le test échoue immédiatement.
  • errors.Is fonctionne toujours quand l’erreur est encapsulée avec fmt.Errorf("...: %w", err). La comparaison err == tc.wantErr ne 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 comme ApplyDiscount, 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 bool ou expectCall int, et que le corps de la boucle est rempli de if, vous écrivez de la logique dans votre test. Divisez-le en fonctions de test séparées.
  • Utiliser t.Fatal en dehors de t.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: 50000 avec GIAM50K).

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 via f.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.Is en utilisant un champ wantErr 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.Diff lors 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.
golangtesttest-piloté-par-les-donnéestest-unitaire