Tests unitaires, fuzzying et debugging
Steve Maguire
Never allow the same bug to bite you twice.
Contexte
Nous savons tous que les tests unitaires sont indispensables pour construire du code robuste. Les concepteurs de Go le savent aussi et c’est pourquoi les outils permettant de tester le code sont inclus dans la distribution de Go. Nous allons découvrir ces outils à l’aide d’un exemple.
Supposons que vous deviez écrire une méthode pour renverser une chaîne de caractères. La chaîne de caractère “Hello” deviendrait alors “olleH”.
Voici une première implémentation de cette méthode :
func Reverse(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
On commence par convertir l’argument s en slice et ensuite on
renverse le slice en inversant les éléments
On peut écrire une fonction main pour tester notre code :
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev := Reverse(input)
doubleRev := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q\n", rev)
fmt.Printf("reversed again: %q\n", doubleRev)
}
… et tout semble OK. Le texte renversé deux fois redonne le texte de départ :
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"
Premier test unitaire
Écrivons maintenant un test unitaire pour notre fonction. Pour cela,
il suffit de créer un fichier xxx_test.go dans le même dossier (et
donc dans le même package) que celui qui contient la fonction à
tester. Comme le programme de test est dans le même package, nous
pouvons ainsi tester des fonctions privées (qui ne commencent pas avec
une majuscule).
Ajoutez le fichier reverse_test.go avec le contenu suivant :
func TestReverse(t *testing.T) {
testcases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{" ", " "},
{"!12345", "54321!"},
}
for _, tc := range testcases {
rev := Reverse(tc.in)
if rev != tc.want {
t.Errorf("Reverse: %q, want %q", rev, tc.want)
}
}
}
Le tableau testcases contient des exemples de textes avec le résultat attendu.
Vous pouvez exécuter le test unitaire avec la commande go test suivi du dossier dans
lequel se trouve le package :
> go test ./test
ok example/test 0.193s
On peut obtenir plus de détails en donnant l’option -v (verbose) à la commande :
> go test ./test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
PASS
ok example/test 0.081s
Si on refait le test sans rien changer, le résultat sera repris d’un cache :
> go test ./test
ok example/test (cached)
Per réellement refaire le test, vous pouvez effacer le cache :
> go clean -testcache
ou alors, relancer le test avec l’option -count=1 :
> go test ./test -count=1
ok example/test 0.086s
Détection des problèmes de concurrence
Une bonne pratique consiste à également tester le code contre
les race conditions avec l’option -race :
> go test ./test -race
ok example/test 0.196s
Avec cette option, Go simule plusieurs threads et vérifie que votre code fonctionne correctement dans un contexte de programmation concurrente.
Illustrons cette fonctionnalité avec un exemple. Soit le type Account qui
permet de gérer un compte bancaire avec les méthodes Withdraw et Deposit :
type Account float64
func (a *Account) Withdraw(amount float64) {
*a -= Account(amount)
time.Sleep(100 * time.Millisecond)
}
func (a *Account) Deposit(amount float64) {
*a += Account(amount)
time.Sleep(100 * time.Millisecond)
}
L’appel à time.Sleep permet de simuler un traitement plus long.
On peut faire un test très simple avec le code suivant :
func TestBasic(t *testing.T) {
var a Account = 0
a.Deposit(100)
if a != 100 {
t.Errorf("a = %f; want 100", a)
}
a.Withdraw(50)
if a != 50 {
t.Errorf("a = %f; want 50", a)
}
}
En exécutant ce test, on voit que tout semble en ordre :
> go test -v .
=== RUN TestBasic
--- PASS: TestBasic (0.20s)
PASS
Maintenant supposons que l’ajout ou le retrait d’argent est une opération qui prend beaucoup de temps et que nous souhaitons les exécuter dans des processus séparés. Nous modifions le code comme suit :
type Account float64
func (a *Account) Withdraw(amount float64) {
go func() {
*a -= Account(amount)
}()
time.Sleep(100 * time.Millisecond)
}
func (a *Account) Deposit(amount float64) {
go func() {
*a += Account(amount)
}()
time.Sleep(100 * time.Millisecond)
}
Le test passe toujours :
> go test -v .
=== RUN TestBasic
--- PASS: TestBasic (0.20s)
PASS
Mais le code est maintenant faux car il s’y cache une race condition.
On peut mettre cette erreur en lumière en ajoutant l’option -race :
> go test -v -race .
=== RUN TestBasic
==================
WARNING: DATA RACE
Read at 0x... by goroutine 6:
.../race.TestBasic()
.../account_test.go:10 +0x54
...
Previous write at 0x... by goroutine 7:
.../race.(*Account).Deposit.func1()
.../account.go:16 +0x48
...
...
==================
testing.go:1465: race detected during execution of test
--- FAIL: TestBasic (0.21s)
=== NAME
testing.go:1465: race detected during execution of test
FAIL
La ligne 10 de account_test.go correspond au test if a != 100
et la ligne 16 de account.go correspond à la ligne *a += Account(amount).
Le message d’erreur “Read … Previous write” indique que la variable est lue par un thread alors qu’un autre thread est en train de la modifier. C’est un cas typique de race condition.
L’excellente vidéo de Kavya Joshi filmée lors de la conférence Strange Loop (aussi une excellent conférence) explique les détails de ce genre d’erreur et explique aussi comment Go détecte ces erreurs.
Couverture du code
Go permet aussi de tester la couverture du code. Expérimentez avec l’option -cover :
> go test ./test -cover
ok example/test 0.212s coverage: 40.0% of statements
Vous constatez que vos tests unitaires couvrent 40%. Pour plus de détails, laissez Go générer un profil de couverture :
> go test ./test -coverprofile=coverage.out
ok example/test 0.087s coverage: 40.0% of statements
Vous pouvez maintenant analyser le contenu du profil avec la commande suivante :
go tool cover -html=coverage.out
Go affiche le profile de couverture dans votre navigateur web et vous permet de bien visualiser le code couvert (en vert) et le code non couvert (en rouge) :

Tests de performance
Go permet aussi de faire des tests de performance avec les benchmarks. Supposons que
nous souhaitons connaître le temps que prend la fonction Reverse sur le texte Hello, world,
nous pouvons compléter le fichier reverse_test.go avec le code suivant :
func BenchmarkReverse(b *testing.B) {
for n := 0; n < b.N; n++ {
Reverse("Hello, world")
}
}
Note
Vous auriez aussi pu créer un autre fichier xxx_test.go, mais ce n’est pas
nécessaire.
Lancez maintenant les tests avec l’option -bench=. :
> go test ./test -bench=.
goos: darwin
goarch: arm64
pkg: example/test
BenchmarkReverse-10 161014772 7.183 ns/op
PASS
ok example/test 2.128s
Go a estimé le nombre de fois qu’il devait répéter l’opération pour obtenir un résultat cohérent et dans l’exemple ci-dessus, il a effectué l’opération 161014772 fois et qu’en moyenne, l’opération lui a pris 7.183 nanosecondes.
Note
Le -10 qui suit le nom de la méthode (BenchmarkReverse-10) indique que le
code a tourné sur une machine avec 10 cœurs. Vous pouvez aussi limiter le nombre
de cœurs utilisés avec la variable d’environnement GOMAXPROCS. Par
exemple GOMAXPROCS=1 go test ./test -bench=. n’utilise qu’un seul cœur.
Les assertions
Les fonctions de tests que nous avons vues n’utilisent que des packages de la librairie standard de Go. En particulier les tests sont de simples comparaisons du genre :
if actual != wanted {
t.Errorf("actual: %v, wanted %v", actual, wanted)
}
Le package github.com/stretchr/testify/assert
permet de simplifier ces constructions avec des assertions.
Avec ce package, la fonction TestReverse peut s’écrire ainsi :
func TestReverse(t *testing.T) {
testcases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{" ", " "},
{"!12345", "54321!"},
}
for _, tc := range testcases {
rev := Reverse(tc.in)
assert.Equal(t, tc.want, rev)
}
}
Fuzzing
Edsger W. Dijkstra
Program testing can be used to show the presence of bugs, but never to show their absence!
Nous avons vu plusieurs techniques pour tester la fonction Reverse qui permet de renverser et
nous avons même une couverture de 100% de cette fonction, mais est-ce qu’elle est vraiment correcte ?
Nous n’avons testé que 3 cas et tout ce que nous pouvons affirmer, c’est que pour ces 3 cas, le programme
fonctionne correctement. Mais n’y a-t-il pas d’autres cas de figure qui donneraient des résultats erronés ?
Pour découvrir ces cas de figure, nous pouvons utiliser la technique de fuzzing.
À partir de la version 1.18 de Go, les outils qui permettent de faire du fuzzing sont intégrés au système de base.
Complétons le fichier reverse_test.go avec la fonction suivante :
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
assert.Equal(t, orig, doubleRev)
assert.False(
t, utf8.ValidString(orig) && !utf8.ValidString(rev),
"Reverse produced invalid UTF-8 string")
})
}
Le fuzzing génère des paramètres aléatoires et la fonction de test ne peut pas comparer le résultat avec une valeur attendue. Nous ré-écrivons le test en vérifiant qu’un double renversement de la chaîne de caractère redonne la même chose que la chaîne initiale. On ajoute aussi un deuxième test qui vérifie que si l’argument est une chaîne de caractère valide UTF-8, alors le renversement est aussi valide.
On refait le test normal et on n’observe rien de nouveau :
> go test ./test
ok example/test 0.085s
Avec l’option -v, on note cependant que FuzzReverse a bien fait 3 tests supplémentaires :
❯ go test ./test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
=== RUN FuzzReverse
=== RUN FuzzReverse/seed#0
=== RUN FuzzReverse/seed#1
=== RUN FuzzReverse/seed#2
--- PASS: FuzzReverse (0.00s)
--- PASS: FuzzReverse/seed#0 (0.00s)
--- PASS: FuzzReverse/seed#1 (0.00s)
--- PASS: FuzzReverse/seed#2 (0.00s)
PASS
ok example/test 0.175s
Ces 3 tests supplémentaires (notés FuzzReverse/seed#0, FuzzReverse/seed#1 et FuzzReverse/seed#2)
correspondent aux testcases initiaux de la fonction FuzzReverse.
On peut maintenant demander à Go de tester avec de nouvelles valeurs aléatoires :
> go test ./test -fuzz=.
fuzz: elapsed: 0s, gathering baseline coverage: 0/16 completed
fuzz: elapsed: 0s, gathering baseline coverage: 16/16 completed, now fuzzing with 10 workers
fuzz: minimizing 44-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzReverse (0.06s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:39:
Error Trace: test/reverse_test.go:39
test/value.go:584
test/value.go:368
test/fuzz.go:337
Error: Should be false
Test: FuzzReverse
Messages: Reverse produced invalid UTF-8 string "\x90\xcc"
Failing input written to testdata/fuzz/FuzzReverse/af4a841cad779857...
To re-run:
go test -run=FuzzReverse/af4a841cad779857...
FAIL
exit status 1
FAIL example/test 0.170s
Le cas qui a provoqué l’erreur est stocké dans le fichier testdata/fuzz/FuzzReverse/....
Si on refait un go test sans l’option -fuzz, on observe encore le problème:
> go test ./test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/af4a841cad779857... (0.00s)
reverse_test.go:39:
Error Trace: test/reverse_test.go:39
test/value.go:584
test/value.go:368
test/fuzz.go:337
Error: Should be false
Test: FuzzReverse/af4a841cad779857...
Messages: Reverse produced invalid UTF-8 string "\x90\xcc"
FAIL
FAIL example/test 0.105s
FAIL
Ce comportement permet de débugger le code avec le débogueur. Profitons de cette occasion pour expérimenter le débogueur de Go dans VsCode.

Cliquez sur l’icône du débugger (1) et cliquez sur create a launch.json file.
Dans les options proposées, choisissez Go: Launch package et remplacez le contenu de
launch.json par :
{
"configurations": [
{
"name": "Launch test reverse",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${workspaceFolder}/test",
"args": [
"-test.run",
"FuzzReverse"
]
}
]
}
Adaptez le "program" avec le dossier qui contient le programme à tester.
Cliquez sur la configuration générée :

et le message d’erreur apparaît dans la console de VsCode :

Il est temps maintenant d’ajouter des break points pour observer le comportement du code.
On observe qu’avant l’inversion, le slice b contient les valeurs 204 et 144 :

Et après l’inversion, on a bien les valeurs 144 et 204 :

Si on avait des caractères sur 8 bits, ça serait correct, mais les strings en Go
peuvent contenir des caractères unicode encodés en UTF-8. On ne doit donc pas
utiliser un tableau de bytes mais plutôt un tableau de runes.
Nous corrigeons donc la fonction Reverse ainsi :
func Reverse(s string) string {
b := []rune(s)
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
Le programme principal fonctionne toujours :
> go run ./test
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"
Et les tests passent maintenant tous :
> go test ./test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
=== RUN FuzzReverse
=== RUN FuzzReverse/seed#0
=== RUN FuzzReverse/seed#1
=== RUN FuzzReverse/seed#2
=== RUN FuzzReverse/af4a841cad779857...
--- PASS: FuzzReverse (0.00s)
--- PASS: FuzzReverse/seed#0 (0.00s)
--- PASS: FuzzReverse/seed#1 (0.00s)
--- PASS: FuzzReverse/seed#2 (0.00s)
--- PASS: FuzzReverse/af4a841cad779857... (0.00s)
PASS
ok example/test 0.104s
Ajoutons des valeurs aléatoires en utilisant à nouveau l’option -fuzz. Après peu de temps,
nous observons une nouvelle erreur :
> go test ./test -fuzz=.
fuzz: elapsed: 0s, gathering baseline coverage: 0/19 completed
fuzz: minimizing 41-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 5/19 completed
--- FAIL: FuzzReverse (0.04s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:38:
Error Trace: test/reverse_test.go:38
test/value.go:584
test/value.go:368
test/fuzz.go:337
Error: Not equal:
expected: "\xcc"
actual : "�"
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-�
+�
Test: FuzzReverse
Failing input written to testdata/fuzz/FuzzReverse/6d100c5d8fbd3bc5...
To re-run:
go test -run=FuzzReverse/6d100c5d8fbd3bc5...
FAIL
exit status 1
FAIL example/test 0.339s
Que se passe-t-il maintenant ? On peut reprendre le débogueur et analyser le comportement et
on s’apercevra que le fuzzing a appelé la fonction Reverse avec une chaîne de caractères
qui ne respecte pas les règles de l’UTF-8.
On modifie donc la fonction Reverse pour qu’elle retourne une erreur si l’argument n’est pas valide:
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
et on adapte la fonction main :
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleRevErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}
FuzzReverse :
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := Reverse(orig)
if err1 != nil {
t.Skip("Reverse returned error")
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
t.Skip("Double Reverse returned error")
}
assert.Equal(t, orig, doubleRev)
assert.False(
t, utf8.ValidString(orig) && !utf8.ValidString(rev),
"Reverse produced invalid UTF-8 string %q", rev)
})
}
Et on peut tester à nouveau avec l’option -fuzz=.. Notez que Go
ajoute constamment de nouveaux tests et si le programme se comporte
correctement, la commande go test ne s’arrêtera pas. On peut
corriger ça en donnant une limite de temps au fuzzing. Par exemple 30 secondes :
❯ go test ./test -fuzz=. -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/45 completed
fuzz: elapsed: 0s, gathering baseline coverage: 45/45 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 605043 (201664/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 6s, execs: 1194391 (196448/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 9s, execs: 1814321 (206661/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 12s, execs: 2436850 (207505/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 15s, execs: 3042487 (201818/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 18s, execs: 3654304 (203955/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 21s, execs: 4265200 (203671/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 24s, execs: 4876820 (203842/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 27s, execs: 5483245 (202181/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 30s, execs: 6103842 (206648/sec), new interesting: 0 (total: 45)
fuzz: elapsed: 30s, execs: 6103842 (0/sec), new interesting: 0 (total: 45)
PASS
ok example/test 30.225s
Go aura testé plus de 6 millions de valeurs sans trouver d’erreur !
Pour l’anectdote, une vulnérabilité critique de la bibliothèque standard de Go a été découverte en 2020 grâce au fuzzing. Vous pouvez lire les détails dans l’article The importance of continuity in fuzzing - CVE-2020-28362.
Conclusion
Nous avons vu que Go nous offre tous les outils dont nous avons besoin pour implémenter des tests efficaces. La technique de fuzzing permet même de découvrir de nouvelles erreurs auxquelles les développeurs n’ont pas pensé, mais qui pourraient quand même ouvrir des brèches exploitables par des malfaiteurs. Vous n’avez donc aucune excuse pour ne pas tester vos programmes en Go !
John F. Woods
Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live.