Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Testify : assertions lisibles et test suites structurées

Ou : Comment une API qui se lit comme une phrase finit par écrire des tests qui ne testent rien

Tu écris un test en Go. Trois lignes : tu setup un input, tu appelles la fonction, tu compares la sortie. Le test natif te force à écrire if got != want { t.Errorf("got %v, want %v", got, want) } à chaque fois, et tu te dis qu’il y a forcément mieux. Tu installes Testify. D’un coup, ton test devient assert.Equal(t, want, got). Tu respires. C’est joli. C’est lisible. Tu en mets partout.

Trois mois plus tard, un collègue ouvre ton fichier de tests. Il voit require.NoError(t, err) suivi de cinq assert.Equal, et il se demande pourquoi la moitié stoppe le test et l’autre non. Il voit suite.Suite avec un SetupTest et un SetupSuite qui font des choses différentes, et il se demande lequel est appelé combien de fois. Il voit assert.Equal(t, expected, complexStruct) qui compare deux structs énormes et renvoie un diff illisible. Il ferme le fichier. Il réécrit les tests en natif.

C’est l’histoire de Testify. Un outil qui résout un vrai problème (le testing natif est volontairement minimaliste) en en créant un nouveau : tes tests ressemblent à de la prose anglaise, donc tu arrêtes de les lire comme du code, donc tu arrêtes de remarquer quand ils mentent.

Dans les cinq articles précédents de la série — du manifeste sur la valeur ajoutée du testing face à l’IA aux table-driven tests, en passant par t.Run et t.Parallel, httptest et les interfaces comme mocks — on a systématiquement préféré le testing natif, parfois avec l’excuse que Testify arriverait « plus tard ». Plus tard, c’est aujourd’hui. On regarde la moitié qui reste : les assertions (assert et require) et les suites (suite.Suite). C’est la raison pour laquelle la quasi-totalité des projets Go professionnels importent github.com/stretchr/testify. C’est aussi la raison pour laquelle une partie non négligeable de ces projets auraient intérêt à le désimporter.


Testify en Go : assertions assert vs require, suites de tests et pièges à éviter

La bibliothèque la plus utilisée de l’écosystème Go (et pourquoi)

Testify est la suite de testing tierce la plus utilisée en Go, par une marge énorme. Scanner les go.mod des projets open source un peu sérieux, c’est presque toujours la voir apparaître, souvent en deuxième position après la stdlib. Elle existe depuis une douzaine d’années1, elle est maintenue activement, et elle fait quatre choses :

  1. assert — un package d’assertions expressives qui continuent en cas d’échec.
  2. require — les mêmes assertions, mais qui stoppent le test en cas d’échec.
  3. mock — un système de mocks (qu’on a couvert dans l’article précédent).
  4. suite — des suites de tests avec hooks de setup/teardown.

Aujourd’hui on ne parle que des points 1, 2 et 4. Le mock de Testify, on l’a déjà vu — et de toute façon, pour les mocks, on a conclu qu’il fallait préférer go.uber.org/mock ou un fake manuel selon le contexte.

La raison pour laquelle Testify s’est imposé tient en une phrase : le testing natif de Go est délibérément minimaliste, et ce minimalisme a un coût. testing.T te donne Error, Errorf, Fatal, Fatalf, Log, Logf, Skip, et c’est à peu près tout. Tu veux comparer deux structs ? Tu écris if !reflect.DeepEqual(got, want) { ... } à la main. Tu veux vérifier qu’une slice contient un élément ? Tu écris une boucle. Tu veux vérifier qu’une map a N clés ? Boucle. Tu veux vérifier qu’une erreur est d’un type spécifique ? errors.As plus un if. Tous ces patterns sont trivialement faisables, mais chacun prend trois à cinq lignes au lieu d’une, et le signal est noyé dans le bruit.

Testify dit : on te donne ces trois-cinq lignes, on les appelle assert.Equal, assert.Contains, assert.Len, assert.ErrorAs, et voilà, tes tests font la moitié de la taille et disent trois fois plus clairement ce qu’ils vérifient.

Le pitch marche. Il marche tellement bien que la question n’est plus « est-ce que j’installe Testify ? » mais « est-ce que j’arrive encore à écrire un test Go sans l’utiliser ? ».


assert vs require : la différence que tout le monde ignore pendant deux ans

Dans Testify, tout existe en double. Chaque fonction qui vit dans le package assert existe aussi dans le package require, avec la même signature et le même nom. Tu peux écrire :

1
2
3
4
5
6
7
8
9
import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestSomething(t *testing.T) {
    assert.Equal(t, 42, compute())
    require.Equal(t, 42, compute())
}

Les deux lignes font visuellement la même chose. Sauf que l’une laisse le test continuer en cas d’échec, et l’autre l’arrête net.

Sous le capot, assert.Equal signale l’échec via t.Errorf et laisse le test continuer. require.Equal fait la même chose, puis appelle t.FailNow() pour stopper le test — l’équivalent comportemental d’un t.Fatalf, mais en deux étapes. C’est toute la différence. Elle est minuscule en apparence. Elle est énorme en pratique.

Prends ce test, écrit par l’IA pour valider un utilisateur récupéré depuis la base :

1
2
3
4
5
6
7
func TestGetUser(t *testing.T) {
    user, err := repo.GetUser(42)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
    assert.Equal(t, "alice@example.com", user.Email)
    assert.True(t, user.Verified)
}

Si err n’est pas nil, user est probablement nil ou vide. Le premier assert.NoError échoue. Le test continue. Il tente d’évaluer user.Name sur un *User nil. Panic. Stack trace illisible. Tu passes quinze minutes à comprendre que le vrai problème, c’était la DB non connectée.

Le test bien écrit :

1
2
3
4
5
6
7
8
9
func TestGetUser(t *testing.T) {
    user, err := repo.GetUser(42)
    require.NoError(t, err)
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)
    assert.Equal(t, "alice@example.com", user.Email)
    assert.True(t, user.Verified)
}

Les pré-conditions (erreur nil, pointeur non nil) utilisent require : si l’une des deux échoue, le test s’arrête immédiatement, tu lis un message d’erreur clair au lieu d’une panic. Les vérifications de propriétés indépendantes utilisent assert : si Name est faux et Email est faux, tu veux voir les deux messages d’erreur d’un coup, pas juste le premier.

assert continue, require stoppe — même signature, deux comportements différents

La règle pratique :

require pour les pré-conditions et les invariants dont la violation rend impossible la suite du test. assert pour les vérifications de propriétés indépendantes dont tu veux connaître toutes les violations simultanément.

Cette règle est ignorée par 80 % des projets Go que j’ai lus, et presque systématiquement par l’IA. Le pattern par défaut de l’IA, c’est assert.X partout, parce que assert est écrit plus souvent dans son corpus d’entraînement. Quand le test échoue avec une panic, elle ne comprend pas pourquoi — et toi non plus, si tu n’as jamais fait attention à la distinction.

Le filtre de review, à appliquer à chaque test que tu touches :

Est-ce que la ligne suivante a un sens si celle-ci échoue ?

Si la réponse est non, la ligne courante doit être un require, pas un assert.


Les assertions qui font gagner du temps (et celles qui en font perdre)

Testify expose plus de deux cents fonctions d’assertion2. La plupart sont du sucre syntaxique utile. Certaines sont des pièges.

Celles qui sont objectivement meilleures que le code natif équivalent :

1
2
3
4
5
6
assert.Equal(t, expected, actual)           // au lieu de reflect.DeepEqual + if
assert.NoError(t, err)                       // au lieu de if err != nil
assert.ErrorIs(t, err, io.EOF)               // au lieu de errors.Is + if
assert.Contains(t, collection, element)      // au lieu d'une boucle
assert.Len(t, slice, 3)                      // au lieu de len() + if
assert.ElementsMatch(t, []int{1,2,3}, got)   // comparaison sans ordre

Ces fonctions font exactement ce que tu aurais écrit à la main, en une ligne, avec un message d’erreur automatiquement formaté qui te dit lequel des deux arguments est expected et lequel est actual. C’est un gain net.

Celles qui sont un piège :

1
assert.True(t, user.Age > 18)

Techniquement, ça marche. En cas d’échec, le message d’erreur te dit : “expected true, got false”. Super. Tu sais maintenant que user.Age > 18 est faux. Tu ne sais pas si user.Age vaut 17, -4, ou 0 parce que la DB n’a rien retourné. Préfère toujours :

1
assert.Greater(t, user.Age, 18)  // message d'erreur : "17 is not greater than 18"

Ou, encore mieux, la forme qui capture la valeur effective :

1
2
3
if user.Age <= 18 {
    t.Errorf("user age: got %d, want > 18", user.Age)
}

assert.True et assert.False sont le signe qu’on n’a pas pris le temps de chercher la bonne assertion Testify — ou qu’on aurait dû rester en natif.

Celle qui tue vraiment, assert.Equal sur de grosses structs :

1
assert.Equal(t, expectedOrder, actualOrder)

Si les deux Order ont dix champs et que trois diffèrent, le message d’erreur te donne un dump textuel des deux structs, ligne par ligne, et tu dois chercher visuellement les différences. Plus la struct est grosse, plus tu perds de temps. Pour les gros objets, assert.Equal des champs individuels est presque toujours plus utile :

1
2
3
assert.Equal(t, expectedOrder.ID, actualOrder.ID)
assert.Equal(t, expectedOrder.Total, actualOrder.Total)
assert.Equal(t, expectedOrder.Items, actualOrder.Items)

Ou, si tu veux vraiment comparer la struct entière, utilise une lib de diff comme google/go-cmp avec un format lisible :

1
2
3
if diff := cmp.Diff(expectedOrder, actualOrder); diff != "" {
    t.Errorf("order mismatch (-want +got):\n%s", diff)
}

C’est un peu ironique : la fonction la plus utilisée de Testify (assert.Equal) est celle qui vieillit le plus mal quand la complexité du test augmente.


suite.Suite : la vieille idée OOP qui revient par la fenêtre

Go n’a pas de classe. Go n’a pas d’héritage. Go essaie activement de te décourager de faire de l’OOP. Et pourtant, testify/suite te permet d’écrire ça :

 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
type UserRepoSuite struct {
    suite.Suite
    db   *sql.DB
    repo *UserRepository
}

func (s *UserRepoSuite) SetupSuite() {
    s.db = openTestDB(s.T())
    runMigrations(s.T(), s.db)
}

func (s *UserRepoSuite) TearDownSuite() {
    s.db.Close()
}

func (s *UserRepoSuite) SetupTest() {
    s.repo = NewUserRepository(s.db)
    truncateAllTables(s.T(), s.db)
}

func (s *UserRepoSuite) TestCreateUser_Success() {
    err := s.repo.Create(User{Name: "Alice"})
    s.Require().NoError(err)
    s.Equal(1, s.repo.Count())
}

func (s *UserRepoSuite) TestCreateUser_Duplicate() {
    s.repo.Create(User{Name: "Alice"})
    err := s.repo.Create(User{Name: "Alice"})
    s.Error(err)
}

func TestUserRepoSuite(t *testing.T) {
    suite.Run(t, new(UserRepoSuite))
}

C’est très clairement du JUnit. Le pattern SetupSuite / TearDownSuite tourne une fois par suite, SetupTest / TearDownTest tourne avant et après chaque test. Les tests sont des méthodes dont le nom commence par Test. s.Equal, s.Require().NoError, etc. sont des raccourcis qui remplacent assert.Equal(s.T(), ...).

SetupSuite : une fois par suite. SetupTest : avant chaque test. Confondre les deux, c'est multiplier par cent le temps de tes tests.

Ce qui est utile :

  • Tu as de la vraie state partagée entre les tests qui doit être setup/teardown dans un ordre précis (connexion DB, containers, processus externes).
  • Tu as une configuration de test complexe (fixtures, données de seed) que tu factorises dans des méthodes d’helper qui partagent l’état de la suite.
  • Tu veux grouper logiquement des tests qui testent le même sujet sans avoir à répéter le setup dans chaque fonction TestXXX.

Ce qui est un piège :

  • Tu crées des suites pour regrouper des tests qui n’ont aucun state partagé. À ce moment-là, tu payes le coût cognitif de l’abstraction (héritage implicite, ordre d’exécution des hooks, s.T() omniprésent) pour rien. Une simple fonction func setupTest(t *testing.T) ... aurait fait le job en natif.
  • Tu confonds SetupSuite et SetupTest. Ça arrive souvent. Tu ouvres une connexion DB dans SetupTest au lieu de SetupSuite, et tes tests prennent dix secondes au lieu d’un dixième de seconde. Ou l’inverse : tu seed les données dans SetupSuite, le premier test les modifie, et les tests suivants échouent en cascade.
  • Les hooks imbriqués avec t.Parallel() sont un nid à bugs. Les tests parallélisés partagent l’état de la suite, et si tu n’as pas prévu des locks, tu as des races. Testify ne t’aide pas à les détecter3.
  • La composition entre suites est théoriquement possible (tu peux embarquer une suite dans une autre) mais quasi jamais bien faite en pratique. Tu finis par dupliquer le setup.

La règle : les suites de Testify sont utiles quand tu testes un composant stateful (repository, service avec cache persistant, wrapper autour d’une ressource externe). Pour tout le reste — et c’est la majorité — tu n’en as pas besoin.


Le combo gagnant : Testify + table-driven tests

On a vu les table-driven tests dans le deuxième article. On a vu les assertions Testify aujourd’hui. Les deux se combinent naturellement, et c’est là que Testify est le plus clairement gagnant face au natif.

Version native :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  error
    }{
        {"valid simple", "alice@example.com", nil},
        {"missing at", "aliceexample.com", ErrMissingAt},
        {"empty", "", ErrEmpty},
    }

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

Version Testify :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  error
    }{
        {"valid simple", "alice@example.com", nil},
        {"missing at", "aliceexample.com", ErrMissingAt},
        {"empty", "", ErrEmpty},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            err := ValidateEmail(tc.input)
            assert.ErrorIs(t, err, tc.want)
        })
    }
}

L’assertion fait en une ligne ce qui prenait trois lignes en natif, et le message d’erreur est automatiquement formaté avec le nom du sous-test en préfixe. C’est presque toujours un gain net — et c’est probablement le seul contexte où je recommande Testify sans nuance.

Le piège, ici, c’est de mettre require au lieu de assert dans la boucle. Un require qui échoue dans un sous-test stoppe le sous-test et la boucle. Tu perds la visibilité sur les cas suivants. Utilise assert par défaut dans les table-driven tests — sauf si le cas courant a plusieurs vérifications séquentielles dont la première conditionne les suivantes.

Junior Jules : J’ai migré tous nos tests vers Testify la semaine dernière. On a perdu 30 % du volume de code.

Senior Sam : C’est une migration ou un refactoring ?

Junior Jules : Une migration. J’ai juste remplacé if got != want { t.Errorf(...) } par assert.Equal.

Senior Sam : Tu as remplacé partout ?

Junior Jules : Partout.

Senior Sam : Y compris dans les tests où on vérifiait cinq propriétés après un err := repo.Get(...) qui pouvait retourner nil ?

Junior Jules : …ah.

Senior Sam : Donc t’as aussi introduit des panics quand la DB est down. Tu sais combien d’heures ça va nous coûter de remplacer les assert.NoError par require.NoError au bon endroit ?

Junior Jules : Je commence quand ?


L’opinion impopulaire : quand supprimer Testify

Une partie non négligeable des projets Go gagneraient à désimporter Testify. Pas tous. Une partie. Les signaux :

1. Le projet n’utilise assert.Equal, assert.NoError, et assert.NotNil — et rien d’autre.

Si tu n’utilises que trois à cinq fonctions de Testify, tu payes une dépendance externe pour ce qui pourrait être trois fonctions d’helper locales de dix lignes chacune. Le coût d’import (taille du binaire, surface d’attaque supply-chain4, temps de compilation) n’est pas nul. Le natif + un fichier testhelpers/assertions.go de 50 lignes te donne les mêmes ergonomiques sans la dette.

2. Le projet a zéro suite.Suite, zéro require, et zéro mock.

Si Testify est importé uniquement pour les assertions, et qu’aucun test n’utilise require (voir point 1 sur la confusion assert/require), alors personne dans l’équipe n’a vraiment réfléchi à ce que Testify fait. La dépendance a été ajoutée par défaut.

3. Les tests sont saturés d’assert.True et d’assert.Equal sur des grosses structs.

C’est le signe que l’équipe utilise Testify comme un réflexe au lieu de choisir l’assertion la plus précise. Retirer Testify force à écrire le test que tu aurais dû écrire au départ — et souvent à comprendre quel contrat on teste au lieu de taper « compare ces deux choses » sans réfléchir.

4. Les tests sont générés à 80 % par l’IA.

Si ton pipeline de test est « demander à Claude/Copilot de générer un test, le commit tel quel », Testify amplifie le problème. L’IA est à l’aise avec Testify. Elle en produit des volumes industriels. Retirer Testify ralentit l’IA — et t’oblige, toi, à relire ses tests en code Go idiomatique. Ce qui est précisément le job.

À l’inverse, garde Testify si :

  • Tu as de vrais tests d’intégration avec du state (DB, containers) et tu utilises suite.Suite proprement.
  • Ton équipe connaît la distinction assert/require et l’applique systématiquement.
  • Tu utilises assert.ErrorIs, assert.ElementsMatch, assert.JSONEq et d’autres assertions non triviales que tu devrais sinon réimplémenter.
  • Tu utilises mock.Mock et tu n’es pas prêt à migrer vers go.uber.org/mock ou des fakes manuels.

La question n’est pas « Testify est-il bon ? » (il est bon). La question est « Testify est-il bon pour ce projet, utilisé comme ça ? ». Pour une partie des projets Go, la réponse honnête est non.


Migrer dans les deux sens

Une migration natif → Testify est triviale et largement automatisable avec sed. Une migration Testify → natif l’est aussi, à condition d’avoir une table de correspondance claire :

TestifyNatif équivalent
assert.Equal(t, a, b)if !reflect.DeepEqual(a, b) { t.Errorf(...) }
assert.NoError(t, err)if err != nil { t.Errorf(...) }
require.NoError(t, err)if err != nil { t.Fatalf(...) }
assert.ErrorIs(t, err, target)if !errors.Is(err, target) { t.Errorf(...) }
assert.Contains(t, s, sub)if !strings.Contains(s, sub) { t.Errorf(...) }
assert.Len(t, slice, n)if len(slice) != n { t.Errorf(...) }
suite.Suite + méthodes TestXXXfonctions TestXXX + helper setupXXX(t)

Une heure de grep et sed, une passe de go test ./..., et ton projet compile à nouveau sans Testify. Le nombre de lignes augmente de 20-30 %. La lisibilité peut baisser si tu ne fais rien — mais souvent, elle s’améliore, parce que le test explicite ce qu’il vérifie au lieu de le cacher derrière une assertion qui ressemble à de la prose.


Ce que tu peux faire maintenant

  1. Ouvre ton projet. grep -rn "assert\." --include="*_test.go" | wc -l et grep -rn "require\." --include="*_test.go" | wc -l. Si le ratio require/assert est inférieur à 10 %, ton équipe n’a pas assimilé la distinction.
  2. Cherche les assert.True et assert.False. Chacun est un test qui pourrait être remplacé par une assertion plus précise (Greater, Contains, ErrorIs…) ou qui aurait dû être un if natif explicite.
  3. Cherche les assert.Equal sur des structs de plus de cinq champs. Vérifie que chaque champ testé a un sens au regard du contrat. Si trois des dix champs sont des timestamps auto-générés, l’assertion va casser au premier refactoring.
  4. Si tu as des suite.Suite, vérifie que le SetupSuite ne fait que du setup coûteux (DB, containers) et que le SetupTest ne fait que du setup léger (reset de state). Si c’est l’inverse, tes tests sont plus lents qu’ils ne devraient l’être — sensiblement.
  5. Fais le calcul honnête : sur une semaine, combien de fonctions distinctes de Testify ton projet utilise-t-il ? Si c’est moins de huit, le projet est candidat à une migration vers natif + un fichier d’helpers locaux.

La suite

On a vu les table-driven tests, les subtests, httptest, les mocks, et maintenant Testify. C’est la boîte à outils complète pour tester du code Go isolé. Mais le code isolé n’est pas le code qui casse en production. Ce qui casse en production, c’est le code qui interagit avec des inputs qu’on n’a pas prévus.

Dans le prochain article, on sort des sentiers battus. On regarde le fuzzing natif de Go — la feature intégrée à la stdlib depuis Go 1.18 qui te permet de générer des millions d’inputs aléatoires pour trouver les bugs que personne, toi y compris, n’a imaginés. C’est l’arme la plus puissante de la panoplie de testing Go, et c’est aussi celle que l’IA ne sait toujours pas utiliser — parce que ses tests sont basés sur des cas qu’elle anticipe, alors que le fuzzing trouve précisément ceux qu’aucun humain n’aurait anticipés.


  1. Le repo github.com/stretchr/testify a été créé en 2012 par Mat Ryer et Tyler Bunnell sous l’organisation Stretchr, puis repris par une équipe de mainteneurs de la communauté Go. Une v2 a été longuement discutée (épisode Go Time #139, pour les curieux) mais la décision actuelle du mainteneur est claire : pas de v2 breaking sur le repo officiel, la v1.x reste la référence. Au moment d’écrire, la dernière version stable est la v1.11.x, sortie fin 2025. Les évolutions récentes concernent surtout l’ajout d’assertions spécialisées (ErrorContains, EventuallyWithT, EqualExportedValues), pas des changements d’API existante. ↩︎

  2. Plus précisément, le package assert de la v1.11 expose plus de deux cents fonctions exportées — entre les assertions de base, leurs variantes formattées (Equalf, Truef), les variantes HTTP/JSON/YAML/filesystem, et les méthodes sur le type Assertions. Personne ne les connaît toutes — et personne n’a besoin de les connaître. Le sous-ensemble utile tient sur une fiche A5 : Equal, NotEqual, NoError, Error, ErrorIs, ErrorAs, ErrorContains, NotNil, Nil, True, False, Contains, NotContains, Len, Empty, NotEmpty, ElementsMatch, Greater, Less, InDelta, JSONEq, EventuallyWithT. Tout le reste, c’est du plumage. ↩︎

  3. Le go race detector fonctionne bien sûr dans les suites Testify — go test -race reste obligatoire. Le problème, c’est qu’il détecte les data races quand elles se produisent, pas les erreurs de design de la suite. Un SetupSuite qui initialise un état mutable partagé par vingt tests parallèles est un bug d’architecture que seule une relecture humaine détecte. -race ne t’aidera qu’après coup, quand le test sera flaky en CI. ↩︎

  4. La supply chain côté Testify est raisonnablement saine — le repo est sur l’organisation stretchr/, l’historique de contributions est dense et publiquement auditable, et les dépendances restent limitées : davecgh/go-spew et stretchr/objx en directes, pmezard/go-difflib et gopkg.in/yaml.v3 en indirectes. Mais le principe général tient : chaque dépendance ajoutée à go.mod est une surface d’attaque supplémentaire, et les libs de testing sont particulièrement intéressantes pour les attaquants parce qu’elles sont souvent moins auditées que le code de production. Voir l’incident XZ Utils de 2024 pour un rappel de ce qui peut arriver quand une dépendance « stable depuis dix ans » passe entre de mauvaises mains. ↩︎