Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Subtests et t.Run() : isoler, paralléliser, et comprendre ses échecs

Ou : Ce que t.Run() sait faire et que tu ne lui as jamais demandé

Il est 3h du matin. La CI est rouge. Tu ouvres le terminal, tu scrolles les logs, et tu lis :

1
2
--- FAIL: TestUserService/Create/duplicate_email (0.01s)
    user_service_test.go:42: expected error "email already exists", got nil

Tu sais exactement quel test a planté. Tu sais quel comportement est cassé. Tu ouvres le fichier, tu vas à la ligne 42, tu fixes, tu push. Deux minutes. Tu te recouches.

Maintenant, imagine la version alternative :

1
2
--- FAIL: TestUserService (0.03s)
    user_service_test.go:47: got nil

got nil. Merci. Super utile. Tu ouvres le fichier, tu vois une boucle de 15 cas, tu cherches lequel a foiré, tu ajoutes des t.Log partout, tu relances. Quinze minutes plus tard, tu trouves. Le bug était trivial. Le temps perdu, non.

La différence entre ces deux expériences, c’est t.Run(). Tu l’as déjà vu dans l’article précédent — il enveloppait chaque cas du tableau. Mais t.Run() va beaucoup plus loin que ça. Il crée des arbres. Il active la parallélisation. Il permet du debugging chirurgical. Et l’IA, prévisiblement, l’utilise en surface et ignore tout ce qu’il y a en dessous.


Subtests Go et IA : t.Run(), t.Parallel(), t.Helper() et t.Cleanup() en profondeur

t.Run() au-delà du tableau

Dans l’article précédent, t.Run() servait à nommer chaque cas dans un tableau. C’est son usage le plus basique. Mais t.Run() est une fonction récursive — tu peux imbriquer des subtests dans des subtests, créant une arborescence qui reflète la structure de ton code1.

Prenons un UserService avec trois méthodes. L’IA te génère trois fonctions de test séparées : TestCreateUser, TestGetUser, TestDeleteUser. Chacune avec son setup, son teardown, ses cas. C’est comme organiser une bibliothèque en mettant chaque livre sur une étagère différente — techniquement rangé, pratiquement inutilisable.

Voici ce que tu devrais avoir à la place :

 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
func TestUserService(t *testing.T) {
    db := setupTestDB(t)
    svc := NewUserService(db)

    t.Run("Create", func(t *testing.T) {
        t.Run("valid_user", func(t *testing.T) {
            err := svc.Create(User{Name: "Alice", Email: "alice@test.com"})
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
        })
        t.Run("duplicate_email", func(t *testing.T) {
            _ = svc.Create(User{Name: "Alice", Email: "dup@test.com"})
            err := svc.Create(User{Name: "Bob", Email: "dup@test.com"})
            if err == nil {
                t.Fatal("expected error for duplicate email, got nil")
            }
        })
        t.Run("empty_name", func(t *testing.T) {
            err := svc.Create(User{Name: "", Email: "noname@test.com"})
            if err == nil {
                t.Fatal("expected error for empty name, got nil")
            }
        })
    })

    t.Run("Get", func(t *testing.T) {
        t.Run("existing_user", func(t *testing.T) { /* ... */ })
        t.Run("nonexistent_id", func(t *testing.T) { /* ... */ })
    })

    t.Run("Delete", func(t *testing.T) {
        t.Run("soft_delete", func(t *testing.T) { /* ... */ })
        t.Run("already_deleted", func(t *testing.T) { /* ... */ })
    })
}

Les noms sont concaténés avec des slashes : TestUserService/Create/duplicate_email. C’est un chemin. C’est une adresse. Et cette adresse, c’est le superpouvoir que t.Run() te donne pour le debugging.

Un détail que l’IA ne mentionne jamais : t.Run() retourne un bool. true si le subtest a réussi, false sinon. Tu peux conditionner la suite de tes tests là-dessus — par exemple, ne lancer les tests Get que si Create a fonctionné. C’est rare, mais quand tu en as besoin, c’est là2.


Filtrage avec -run : le debugging chirurgical

La vraie puissance des noms hiérarchiques, c’est le flag -run. Il prend une regex et la matche contre le chemin complet du test :

1
2
3
4
5
6
7
8
# Seulement les tests de création
go test -run TestUserService/Create -v

# Un cas précis, quel que soit le parent
go test -run /duplicate_email -v

# Tous les cas d'erreur (regex)
go test -run "TestUserService/.*/empty|nonexistent|already" -v

C’est du matching partiel par défaut — pas besoin d’ancrer avec ^ et $. Et c’est ici que le travail de nommage de l’article précédent paie : si tes cas s’appellent test1, test2, test3, le flag -run est inutile. Si tes cas s’appellent duplicate_email, empty_name, nonexistent_id, tu peux cibler n’importe quel comportement en une commande.

Scénario concret : l’IA t’a généré 47 tests pour ton service. Après un refactor, trois échouent. Au lieu de lancer les 47 et de scroller 200 lignes de sortie, tu fais go test -run /duplicate_email -v. Tu fixes. Tu relances ce seul cas. 30 secondes au lieu de 5 minutes. Multiplié par le nombre de fois que tu debugges dans une journée, ça change ta vie3.


t.Parallel() : quand et comment paralléliser

t.Parallel() est une ligne de code. Une seule. Et elle change tout le modèle d’exécution de tes tests. Voici ce qui se passe quand tu l’appelles dans un subtest :

  1. Le subtest se met en pause.
  2. t.Run() retourne immédiatement dans le parent.
  3. Le parent continue sa boucle, lançant les autres subtests (qui se mettent aussi en pause s’ils appellent t.Parallel()).
  4. Quand la fonction parente termine, tous les subtests en pause se réveillent et s’exécutent en parallèle.

Ce n’est pas du « fire and forget ». C’est du « pause, attends tes frères et sœurs, puis partez tous ensemble ». La nuance est importante.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func TestUserService_Create(t *testing.T) {
    cases := []struct {
        name  string
        input User
        want  string
    }{
        {"valid_user", User{Name: "Alice", Email: "a@test.com"}, ""},
        {"empty_name", User{Name: "", Email: "b@test.com"}, "name required"},
        {"bad_email", User{Name: "Bob", Email: "invalid"}, "invalid email"},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            err := validate(tc.input)
            assertError(t, err, tc.want)
        })
    }
}

Tu remarques qu’il n’y a pas de tc := tc avant t.Run(). Avant Go 1.22, c’était un bug critique. La variable de boucle tc était partagée entre toutes les itérations. Comme t.Parallel() met le subtest en pause, la boucle continuait, et quand les goroutines se réveillaient, elles lisaient toutes la dernière valeur de tc. Tous tes tests testaient le même cas — le dernier du tableau4.

Go 1.22 a corrigé ça en donnant à chaque itération sa propre copie de la variable. Mais — et c’est le piège — ce comportement est contrôlé par la directive go dans ton go.mod. Si ton module déclare go 1.21, tu gardes l’ancien comportement, même avec un toolchain Go 1.22+. L’IA ne vérifie pas ton go.mod. Elle génère du code qui pourrait marcher ou pourrait tester le même cas 15 fois, selon une ligne dans un fichier de config qu’elle n’a pas lu5.

Junior Jules : J’ai ajouté t.Parallel() partout, les tests passent 3x plus vite !

Senior Sam : Et ils testent tous le même cas. Le dernier du tableau.

Junior Jules : …ah.

Quand ne pas utiliser t.Parallel() : quand tes tests partagent un état mutable (une base de données, un fichier, une variable globale), quand l’ordre d’exécution compte, quand tu testes des effets de bord qui peuvent se marcher dessus. L’IA l’ajoute par défaut, sans se poser ces questions. Toi, tu dois les poser.


t.Helper() : des stack traces qui montrent le vrai problème

Quand tu écris des helpers de test — des fonctions qui font des assertions réutilisables — tu veux que les erreurs pointent vers l’endroit où le helper est appelé, pas vers l’intérieur du helper lui-même.

Sans t.Helper() :

1
2
3
4
5
6
func assertError(t *testing.T, got error, wantContains string) {
    if wantContains == "" && got != nil {
        t.Fatalf("unexpected error: %v", got)
    }
    // ...
}
1
2
--- FAIL: TestUserService/Create/valid_user (0.00s)
    helpers_test.go:12: unexpected error: connection refused

helpers_test.go:12. Super. Tu ouvres le fichier des helpers. Tu vois t.Fatalf("unexpected error: %v", got). Ça ne te dit pas quel test a provoqué l’erreur. Tu remontes la stack manuellement.

Avec t.Helper() :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func assertError(t *testing.T, got error, wantContains string) {
    t.Helper()
    if wantContains == "" && got != nil {
        t.Fatalf("unexpected error: %v", got)
    }
    if wantContains != "" {
        if got == nil {
            t.Fatalf("expected error containing %q, got nil", wantContains)
        }
        if !strings.Contains(got.Error(), wantContains) {
            t.Fatalf("error %q does not contain %q", got, wantContains)
        }
    }
}
1
2
--- FAIL: TestUserService/Create/valid_user (0.00s)
    user_service_test.go:38: unexpected error: connection refused

user_service_test.go:38. Ligne exacte de l’appel dans ton test. Tu sais immédiatement quel cas a échoué et pourquoi. C’est la différence entre 30 secondes et 5 minutes de debugging — et à 3h du matin, c’est la différence entre se recoucher et rester debout6.

La règle est simple : si ta fonction prend un *testing.T et appelle t.Error, t.Fatal, ou n’importe quelle méthode de reporting, mets t.Helper() en première ligne. L’IA ne le fait presque jamais. Ses helpers compilent et fonctionnent — mais ils sont indébuggables.


t.Cleanup() vs defer : le piège qui attend les subtests parallèles

C’est le piège le plus vicieux de cet article. Et c’est exactement celui que l’IA te tend, à chaque fois.

Tu as une base de données de test. Tu l’ouvres au début du test, tu la fermes à la fin. Réflexe Go classique : defer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func TestWithDB(t *testing.T) {
    db := openTestDB()
    defer db.Close() // Semble correct, non ?

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            result, err := db.Query(tc.query) // BOOM: db is closed
            // ...
        })
    }
}

Souviens-toi de la mécanique de t.Parallel() : les subtests se mettent en pause, le parent continue, et quand le parent terminedefer s’exécute. La base de données se ferme. Puis les subtests parallèles se réveillent et essaient de l’utiliser.

C’est le concierge qui ferme le bâtiment à clé alors que les gens sont encore à l’intérieur.

La solution : t.Cleanup().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func TestWithDB(t *testing.T) {
    db := openTestDB()
    t.Cleanup(func() { db.Close() }) // Attend la fin de TOUS les subtests

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            result, err := db.Query(tc.query) // db est toujours ouverte
            // ...
        })
    }
}

t.Cleanup() est conscient du système de tests. Il sait que des subtests parallèles existent. Il attend que tout le monde ait fini avant d’exécuter la fonction de nettoyage. defer, lui, ne sait rien — il s’exécute quand la fonction Go retourne, point7.

Comme defer, t.Cleanup() s’exécute en LIFO : le dernier enregistré s’exécute en premier. Et comme defer, tu peux en empiler autant que tu veux. La seule différence : t.Cleanup() respecte le cycle de vie des tests, pas juste celui de la fonction.

La règle : si n’importe quel subtest appelle t.Parallel(), remplace tous les defer du parent par t.Cleanup(). L’IA génère defer par réflexe — elle pense en termes de fonctions Go, pas en termes de cycle de vie de tests.


Tout assembler : le pattern complet

Voici à quoi ressemble un test qui utilise correctement les quatre outils :

 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
49
50
func TestUserService(t *testing.T) {
    db := setupTestDB(t)
    svc := NewUserService(db)

    t.Run("Create", func(t *testing.T) {
        cases := []struct {
            name    string
            input   CreateUserRequest
            wantErr string
        }{
            {"valid_user", CreateUserRequest{Name: "Alice", Email: "alice@test.com"}, ""},
            {"duplicate_email", CreateUserRequest{Name: "Bob", Email: "alice@test.com"}, "email already exists"},
            {"empty_name", CreateUserRequest{Name: "", Email: "bob@test.com"}, "name required"},
        }

        for _, tc := range cases {
            t.Run(tc.name, func(t *testing.T) {
                t.Parallel()
                err := svc.Create(tc.input)
                assertError(t, err, tc.wantErr)
            })
        }
    })
}

func setupTestDB(t *testing.T) *DB {
    t.Helper()
    db, err := openDB("test")
    if err != nil {
        t.Fatalf("failed to open test DB: %v", err)
    }
    t.Cleanup(func() { db.Close() })
    return db
}

func assertError(t *testing.T, got error, wantContains string) {
    t.Helper()
    if wantContains == "" {
        if got != nil {
            t.Fatalf("unexpected error: %v", got)
        }
        return
    }
    if got == nil {
        t.Fatalf("expected error containing %q, got nil", wantContains)
    }
    if !strings.Contains(got.Error(), wantContains) {
        t.Fatalf("error %q does not contain %q", got, wantContains)
    }
}

Regarde setupTestDB : trois lignes qui font trois choses justes. t.Helper() pour que les erreurs pointent vers l’appelant. t.Cleanup() pour que la base se ferme après tous les subtests. Et un t.Fatalf si le setup échoue — parce qu’il n’y a aucune raison de continuer un test si la base ne s’ouvre pas.

C’est ça le pattern que l’IA devrait générer. Ce n’est pas ce qu’elle génère.


Ce que tu peux faire maintenant

  1. Ouvre un fichier _test.go avec des subtests dans ton projet.
  2. Vérifie : tes helpers appellent-ils t.Helper() ?
  3. Vérifie : si un subtest appelle t.Parallel(), le parent utilise-t-il t.Cleanup() au lieu de defer ?
  4. Lance go test -run TestQuelqueChose/cas_precis -v — est-ce que tu peux cibler un seul cas en échec ?
  5. Lis les noms dans la sortie -v. S’ils ne racontent pas une histoire, renomme-les.

Si tu trouves un defer dans un parent de subtests parallèles, tu viens de trouver un bug en attente.


La suite

On sait maintenant structurer des tests, les nommer, les paralléliser, les filtrer, et les nettoyer proprement. Mais tout ça, c’était pour des fonctions Go classiques. Dans le prochain article, on passe aux handlers HTTP — et Go a un outil extraordinaire pour ça : httptest. On va tester des APIs complètes sans lancer de serveur, et surtout, on va voir pourquoi l’IA ne teste jamais les status codes d’erreur.


  1. Le pattern des subtests est documenté en détail dans le blog officiel Go — Using Subtests and Sub-benchmarks. Le support a été ajouté dans Go 1.7 (août 2016). ↩︎

  2. La valeur de retour de t.Run() est rarement utilisée en pratique, mais elle permet de faire du « gating » : ne lancer un groupe de tests que si un prérequis est validé. Le package testing documente cette fonctionnalité. ↩︎

  3. Le flag -run accepte des expressions régulières complètes. Les slashes séparent les niveaux de subtests. Pour matcher un slash littéral dans un nom de test (par exemple America/New_York), il faut utiliser //. La doc complète est dans go help testflag↩︎

  4. Le changement de scoping des variables de boucle est documenté dans Fixing For Loops in Go 1.22. C’est l’un des rares changements de sémantique du langage depuis Go 1.0. Le comportement est contrôlé par la directive go dans go.mod, ce qui garantit la rétrocompatibilité. ↩︎

  5. Si tu veux vérifier quel comportement ton projet utilise : regarde la première ligne non-commentaire de go.mod. Si elle dit go 1.22 ou plus, les variables de boucle sont par itération. Si elle dit go 1.21 ou moins, elles sont partagées. La nuance : tu peux avoir Go 1.24 installé et quand même utiliser l’ancien comportement si ton go.mod n’a pas été mis à jour. ↩︎

  6. t.Helper() fonctionne aussi avec *testing.B (benchmarks) et *testing.F (fuzzing). Si tu écris des helpers réutilisables entre tests unitaires et benchmarks, la même ligne suffit. Le mécanisme est décrit dans le design proposal original↩︎

  7. t.Cleanup() a été ajouté dans Go 1.14. L’article de Brandur Leach explique en détail pourquoi defer est dangereux avec des subtests parallèles. Le linter tparallel peut détecter automatiquement ce pattern. ↩︎