Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Table-driven tests en Go : le pattern que tout le monde utilise et que personne ne maîtrise

Ou : Comment un slice de structs est devenu le test de Turing des développeurs Go

Il y a un pattern en Go que tout le monde connaît, que tout le monde utilise, et que personne ne remet jamais en question. Le table-driven test. Tu ouvres un fichier _test.go dans n’importe quel projet Go sérieux, tu tombes dessus. Tu demandes à Claude, Copilot ou Cursor d’écrire des tests, ils te le sortent en trois secondes. C’est le SELECT * FROM du testing Go : la première chose que tout le monde apprend, et la dernière chose que tout le monde maîtrise vraiment.

Dans l’article précédent, on a posé le manifeste : en 2026, ton boulot n’est plus d’écrire du code, c’est de valider celui que l’IA produit. Aujourd’hui, on passe à la pratique. Et on commence par le pattern le plus basique, le plus omniprésent, et — paradoxalement — le plus mal utilisé du testing en Go.


Table-driven tests Go et IA : anatomie du pattern que les LLM imitent sans comprendre

Ce qu’est un table-driven test (et pourquoi Go adore ça)

L’idée est d’une simplicité désarmante. Au lieu d’écrire une fonction de test par cas, tu mets tous tes cas dans un tableau — un slice de structs, pour être précis — et tu itères dessus1. Comme ça :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func TestAbs(t *testing.T) {
    tests := []struct {
        name  string
        input float64
        want  float64
    }{
        {"positif", 5.0, 5.0},
        {"négatif", -3.0, 3.0},
        {"zéro", 0.0, 0.0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Abs(tt.input)
            if got != tt.want {
                t.Errorf("Abs(%v) = %v, want %v", tt.input, got, tt.want)
            }
        })
    }
}

C’est propre. C’est lisible. Tu ajoutes un cas ? Une ligne dans le tableau. Tu veux savoir ce qui est testé ? Tu lis le tableau. Tu veux lancer un seul cas ? go test -run TestAbs/négatif. Le wiki officiel de Go recommande ce pattern depuis des années2, et il y a de bonnes raisons : ça force à séparer les données du test de la logique du test. C’est de la programmation déclarative appliquée au testing.

Le problème, c’est que l’IA a très bien compris la forme du pattern. Ce qu’elle n’a pas compris, c’est le fond.


Ce que l’IA te génère (et pourquoi c’est un piège)

Prenons un cas concret. Tu as une fonction qui parse un montant financier à partir d’une chaîne de caractères :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package money

import (
    "fmt"
    "strconv"
    "strings"
)

// ParseAmount extrait un montant en centimes depuis une chaîne comme "12.50" ou "12,50"
func ParseAmount(s string) (int64, error) {
    s = strings.TrimSpace(s)
    s = strings.Replace(s, ",", ".", 1)
    f, err := strconv.ParseFloat(s, 64)
    if err != nil {
        return 0, fmt.Errorf("montant invalide: %q", s)
    }
    return int64(f * 100), nil
}

Tu demandes à ton LLM préféré : « Écris des table-driven tests pour ParseAmount. » Tu obtiens quelque chose dans ce goût-là :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func TestParseAmount(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int64
        wantErr bool
    }{
        {"montant simple", "10.00", 1000, false},
        {"avec virgule", "10,50", 1050, false},
        {"entier", "42", 4200, false},
        {"négatif", "-5.00", -500, false},
        {"invalide", "abc", 0, true},
        {"vide", "", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseAmount(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseAmount(%q) error = %v, wantErr %v",
                    tt.input, err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("ParseAmount(%q) = %v, want %v",
                    tt.input, got, tt.want)
            }
        })
    }
}

Ça compile. Ça passe. Le format est irréprochable. Ton instinct te dit « c’est bien ». Ton CI aussi.

Sauf que ce test est un château de cartes.


Les cinq péchés capitaux du table-driven test généré par IA

1. Les edge cases fantômes

Le test ci-dessus ne vérifie pas :

  • "12.999" — trois décimales. int64(12.999 * 100) donne 1299, pas 1300. Ton utilisateur qui paie 12,999 € se fait arrondir vers le bas, silencieusement3.
  • "0.1" + "0.2" — le classique IEEE 754. 0.1 + 0.2 ne vaut pas 0.3 en float64. Tu convertis en centimes via des floats ? Bienvenue dans un monde de micro-erreurs d’arrondi4.
  • " 12.50 " — avec des espaces. Le TrimSpace est là, mais est-il testé ? Non.
  • "12.50€" — avec un symbole de devise. Ça crashe ? Ça retourne zéro ? Personne ne sait.
  • "999999999999.99" — les grands montants. Ça tient dans un int64 ? Oui5. Mais l’IA ne l’a pas vérifié.

L’IA teste ce qu’elle pense être des edge cases. Elle teste « invalide » et « vide » parce que c’est ce que disent les tutoriels. Mais les vrais edge cases — ceux qui cassent en production — nécessitent de comprendre le domaine métier, pas juste la signature de la fonction.

2. Les noms de cas qui ne disent rien

"montant simple", "avec virgule", "invalide" — ce sont des noms de cas. Pas des descriptions de comportement.

Quand un test échoue en CI à 3h du matin, tu vois :

1
--- FAIL: TestParseAmount/montant_simple (0.00s)

Super. « Montant simple » a échoué. Qu’est-ce que ça veut dire ? Tu ouvres le fichier, tu cherches le cas, tu relis le code. Trois minutes de perdu, multipliées par le nombre de tests qui pètent.

Un bon nom de cas, c’est une spécification :

1
2
3
4
5
{"point_décimal_deux_chiffres", "12.50", 1250, false},
{"virgule_convertie_en_point", "12,50", 1250, false},
{"espaces_ignorés", "  12.50  ", 1250, false},
{"erreur_si_lettres", "abc", 0, true},
{"erreur_si_symbole_devise", "12.50€", 0, true},

Quand virgule_convertie_en_point échoue, tu sais exactement quel comportement est cassé, sans ouvrir le fichier. C’est le genre de détail que l’IA considère comme cosmétique et que toi, à 3h du matin, tu considères comme vital6.

3. Les assertions wantErr bool

Le pattern wantErr bool — « je m’attends à une erreur, ou pas » — est le minimum syndical. L’IA l’adore parce qu’il est simple à générer. Mais il ne vérifie pas quelle erreur.

Ta fonction retourne fmt.Errorf("montant invalide: %q", s). Est-ce que le message contient l’input ? Est-ce que c’est un type d’erreur spécifique qu’un appelant peut inspecter avec errors.Is ? On n’en sait rien — le test dit juste « il y a une erreur » ou « il n’y en a pas ».

Pour du code critique, la struct de test devrait inclure un wantErrContains string ou un wantErrType error :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
tests := []struct {
    name           string
    input          string
    want           int64
    wantErr        bool
    wantErrContains string
}{
    {"erreur_si_lettres", "abc", 0, true, "montant invalide"},
    {"erreur_si_vide", "", 0, true, "montant invalide"},
}

4. La structure figée

L’IA génère un seul tableau pour tous les cas. Mais parfois, tu testes deux comportements très différents : le parsing normal et la gestion d’erreurs. Deux tableaux, deux boucles, c’est plus clair :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
func TestParseAmount(t *testing.T) {
    // Cas valides : on vérifie le résultat
    validCases := []struct {
        name  string
        input string
        want  int64
    }{
        {"point_décimal", "12.50", 1250},
        {"virgule", "12,50", 1250},
        {"entier_seul", "42", 4200},
        {"espaces_autour", "  12.50  ", 1250},
        {"zéro", "0", 0},
        {"trois_décimales_tronquées", "12.999", 1299},
    }
    for _, tt := range validCases {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseAmount(tt.input)
            if err != nil {
                t.Fatalf("erreur inattendue: %v", err)
            }
            if got != tt.want {
                t.Errorf("ParseAmount(%q) = %v, want %v", tt.input, got, tt.want)
            }
        })
    }

    // Cas invalides : on vérifie l'erreur
    errorCases := []struct {
        name           string
        input          string
        wantErrContains string
    }{
        {"lettres", "abc", "montant invalide"},
        {"vide", "", "montant invalide"},
        {"symbole_devise", "12.50€", "montant invalide"},
    }
    for _, tt := range errorCases {
        t.Run("erreur/"+tt.name, func(t *testing.T) {
            _, err := ParseAmount(tt.input)
            if err == nil {
                t.Fatal("attendait une erreur, obtenu nil")
            }
            if !strings.Contains(err.Error(), tt.wantErrContains) {
                t.Errorf("erreur = %q, devrait contenir %q", err, tt.wantErrContains)
            }
        })
    }
}

C’est plus long ? Oui. C’est aussi plus lisible, plus maintenable, et chaque cas a exactement les champs qu’il utilise — pas de wantErr bool inutile dans les cas valides, pas de want int64 inutile dans les cas d’erreur7.

5. L’absence du cas « production réelle »

L’IA teste avec des données inventées. "10.00", "abc", "42" — c’est synthétique. Tes vrais utilisateurs vont envoyer "12,50 €", "1 250,00" (séparateur de milliers), "$12.50", ou "-0.00".

Un bon tableau de tests contient au moins 2-3 cas tirés de données réelles — des trucs que tu as vus dans tes logs, dans tes tickets de bug, dans les imports CSV de tes clients. L’IA ne peut pas inventer ces cas, parce qu’elle ne connaît pas ton contexte métier. C’est ton boulot.


Slice de structs vs map : le faux débat

Tu verras parfois des table-driven tests écrits avec une map[string]struct{...} au lieu d’un slice :

1
2
3
4
5
6
7
tests := map[string]struct {
    input string
    want  int64
}{
    "entier":  {"42", 4200},
    "décimal": {"12.50", 1250},
}

L’IA choisit l’un ou l’autre selon son humeur. La vraie réponse : utilise un slice. Toujours8.

Pourquoi ? Parce qu’une map en Go n’a pas d’ordre d’itération garanti. Tes tests s’exécutent dans un ordre aléatoire à chaque run. C’est acceptable en théorie (un test ne devrait pas dépendre de l’ordre), mais en pratique, quand tu debugges un échec intermittent à 3h du matin — oui, encore — tu veux que les tests s’exécutent dans l’ordre où tu les lis. Le slice te donne ça. La map, non.

La seule exception légitime : quand tes cas de test n’ont pas besoin de noms (rare) et que tu testes une fonction pure sans état. Même là, le slice reste plus lisible.


La méthode des 60 secondes : reviewer un table-driven test IA

Voici comment évaluer un table-driven test généré par l’IA en moins d’une minute. Cinq questions, dans l’ordre :

1. Les noms de cas décrivent-ils un comportement ? (10 secondes) Lis la colonne name. Si tu vois "test1", "basic", "valid", "invalid" — c’est de la décoration. Chaque nom doit répondre à la question « quel comportement est vérifié ici ? ».

2. Les edge cases du domaine sont-ils présents ? (15 secondes) Regarde les inputs. Est-ce qu’il y a des valeurs limites ? Des chaînes vides, des nil, des zéros, des nombres négatifs, des très grands nombres ? Si tous les cas sont du « happy path avec des variantes cosmétiques », le tableau est creux.

3. La struct a-t-elle les bons champs ? (10 secondes) Est-ce que chaque champ est utilisé par chaque cas ? Si la moitié des cas ont wantErr: false et n’utilisent jamais le champ erreur, il faut peut-être deux tableaux. Des champs inutilisés, c’est du bruit.

4. Les assertions vérifient-elles le bon niveau ? (15 secondes) wantErr bool ne suffit pas pour du code critique. got != tt.want ne suffit pas si le résultat est un objet complexe. Cherche les comparaisons superficielles — elles cachent des bugs.

5. Manque-t-il un cas de production réelle ? (10 secondes) Si tous les inputs sont "abc", "test@test.com", "42" — c’est du testing de tutoriel. Ajoute au moins un cas tiré d’un vrai scénario utilisateur.

Si tu réponds « non » à deux questions ou plus, le test a besoin de travail. Et c’est exactement pour ça qu’on te paie9.


Ce que ça change dans ta pratique quotidienne

Le table-driven test n’est pas juste un pattern d’organisation. C’est un outil de pensée. Quand tu structures tes cas dans un tableau, tu es forcé de répondre à des questions que le code source ne pose pas :

  • Quels sont les vrais inputs possibles ?
  • Qu’est-ce qui se passe aux frontières ?
  • Quel comportement est garanti, et lequel est un accident d’implémentation ?

L’IA, elle, remplit le tableau comme on remplit un formulaire : mécaniquement, sans se poser ces questions. C’est pour ça que ses table-driven tests ont toujours l’air corrects et sont rarement suffisants.

Ta valeur ajoutée, c’est le contenu du tableau, pas sa forme. La forme, l’IA la maîtrise. Le contenu — les cas qui comptent, les noms qui documentent, les edge cases qui sauvent — c’est toi.


Ce que tu peux faire maintenant

  1. Ouvre un fichier _test.go dans ton projet Go actuel.
  2. Trouve un table-driven test — il y en a forcément un.
  3. Applique la méthode des 60 secondes.
  4. Ajoute les cas manquants. Renomme les cas vagues. Sépare les tableaux si nécessaire.
  5. Lance go test -v -run NomDuTest et vérifie que les noms de cas dans la sortie racontent une histoire lisible.

Si tes noms de cas forment une spécification quand tu les lis de haut en bas, tu as un bon tableau. Si ça ressemble à une liste de courses écrite par quelqu’un d’autre, tu as du travail.


La suite

Le prochain article de la série plonge dans t.Run() et les subtests : comment isoler, paralléliser, et filtrer tes tests pour debugger efficacement quand quelque chose casse. Parce que le tableau, c’est bien. Mais savoir naviguer dans le tableau quand un cas échoue à 3h du matin — oui, c’est un thème récurrent — c’est mieux.


  1. Le pattern est documenté dans le Go Wiki — Table Driven Tests. C’est devenu le pattern par défaut en Go, au point que les outils d’IA le génèrent automatiquement pour n’importe quelle fonction. ↩︎

  2. Le wiki officiel Go maintient une page dédiée aux table-driven tests depuis les premières versions du langage. Le pattern est aussi recommandé dans le livre The Go Programming Language de Donovan et Kernighan (2015). ↩︎

  3. Les erreurs d’arrondi en virgule flottante sont l’un des bugs les plus courants en finance. 12.999 * 100 donne 1299.9000000000001 en IEEE 754, et int64() tronque vers zéro. La solution classique : parser la partie entière et décimale séparément, ou utiliser une bibliothèque comme shopspring/decimal↩︎

  4. Le problème 0.1 + 0.2 ≠ 0.3 est une conséquence de la représentation binaire des nombres à virgule flottante (IEEE 754). En Go, avec des variables float64 : a, b := 0.1, 0.2; fmt.Println(a + b == 0.3) affiche false (le compilateur Go évalue les constantes littérales avec une précision arbitraire, mais à l’exécution les float64 reprennent leurs droits). C’est le genre de bug que l’IA ne teste jamais parce que, techniquement, elle « sait » que les floats sont imprécis — mais elle ne fait pas le lien avec ton cas d’usage. ↩︎

  5. Un int64 va jusqu’à 9 223 372 036 854 775 807. En centimes, ça fait ~92 233 720 368 547 758 €. Tu as de la marge. Sauf si tu travailles pour un gouvernement qui imprime de la monnaie. ↩︎

  6. Le guide officiel Go sur les tests recommande des noms de cas descriptifs pour faciliter le debugging : go test -v affiche chaque sous-test avec son nom. Un bon nom de cas est une mini-documentation du comportement attendu. ↩︎

  7. Séparer les cas valides et les cas d’erreur en deux slices est un pattern défendu par plusieurs articles de la communauté Go. L’avantage principal : chaque struct n’a que les champs nécessaires, ce qui élimine les champs « toujours à zéro » qui polluent la lecture. ↩︎

  8. La recommandation d’utiliser des slices plutôt que des maps pour les table-driven tests est un consensus de la communauté Go. L’ordre d’itération non garanti des maps est documenté dans la spécification du langage et expliqué en détail sur le blog officiel Go↩︎

  9. Cette méthode de review rapide est une synthèse de plusieurs pratiques recommandées dans la communauté Go testing. L’idée de base : si tu ne peux pas évaluer la qualité d’un test en le survolant, le test est mal structuré — et c’est exactement ce qui arrive avec 90% des tests générés par IA. ↩︎