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

> Troisième article de la série Testing Go. Au-delà du t.Run() basique des table-driven tests : nesting, parallélisation avec t.Parallel(), stack traces propres avec t.Helper(), teardown fiable avec t.Cleanup(), et filtrage chirurgical avec -run.


*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 :

```text
--- 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 :

```text
--- 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](/blog/table-driven-tests-go/) — 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 code[^1].

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 :

```go
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 :

```bash
# 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](/blog/table-driven-tests-go/) 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 vie[^3].

---

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

```go
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 tableau[^4].

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 lu[^5].

> **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()` :

```go
func assertError(t *testing.T, got error, wantContains string) {
    if wantContains == "" && got != nil {
        t.Fatalf("unexpected error: %v", got)
    }
    // ...
}
```

```text
--- 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()` :

```go
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)
        }
    }
}
```

```text
--- 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 debout[^6].

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

```go
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 *termine*... `defer` 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()`.

```go
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, point[^7].

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 :

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

<div class="next-article">
<a href="/blog/httptest-go-tester-apis-sans-serveur/">
<span class="next-article__label">Article suivant de la série</span>
<p class="next-article__title">httptest en Go : tester ses APIs HTTP sans jamais lancer de serveur</p>
<p class="next-article__desc">httptest.NewRecorder(), httptest.NewServer(), et le pattern que l'IA ne maîtrise pas : tester les erreurs HTTP autant que le happy path.</p>
</a>
</div>

[^1]: Le pattern des subtests est documenté en détail dans le [blog officiel Go — Using Subtests and Sub-benchmarks](https://go.dev/blog/subtests). 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](https://pkg.go.dev/testing#T.Run) 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](https://go.dev/blog/loopvar-preview). 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](https://go.googlesource.com/proposal/+/master/design/4899-testing-helper.md).

[^7]: `t.Cleanup()` a été ajouté dans Go 1.14. L'article de [Brandur Leach](https://brandur.org/fragments/go-prefer-t-cleanup-with-parallel-subtests) explique en détail pourquoi `defer` est dangereux avec des subtests parallèles. Le linter `tparallel` peut détecter automatiquement ce pattern.


