Préférez les tests structurés en tableau

2019-05-07 Golang

Je suis un grand fan des tests, en particulier les tests unitaires et le TDD (fait correctement, bien sûr). Une pratique qui s’est développée autour des projets Go est l’idée d’un test structuré en tableau. Cet article explore le pourquoi et le comment d’écrire un test structuré en tableau.

Supposons que nous ayons une fonction qui divise les chaînes de caractères :

// Découpe la chaîne de caractère s en sous-ensembles qui sont séparés par sep et
// retourne un tableau de chaînes des valeurs présentes dans s entre ces séparateurs.
func Split(s, sep string) []string {
    var result []string
    i := strings.Index(s, sep)
    for i > -1 {
        result = append(result, s[:i])
        s = s[i+len(sep):]
        i = strings.Index(s, sep)
    }
    return append(result, s)
}

Dans Go, les tests unitaires ne sont que des fonctions Go régulières (avec quelques règles), nous écrivonsdonc un test unitaire pour cette fonction en commençant par créer un fichier dans le même répertoire, avec le même nom de paquet, split.

package split

import (
    "reflect"
    "testing"
)

func TestSplit(t *testing.T) {
    got := Split("a/b/c", "/")
    want := []string{"a", "b", "c"}
    if !reflect.DeepEqual(want, got) {
         t.Fatalf("expected: %v, got: %v", want, got)
    }
}

Les tests ne sont que des fonctions régulières de Go avec quelques règles :

  1. Le nom de la fonction de test doit commencer par Test.
  2. La fonction de test doit prendre un argument de type *testing.T. L’argument *testing.T est un type importé par le package testing, pour fournir les méthodes permettant d’afficher, d’ignorer et d’échouer le test.

Dans notre test, nous appelons la fonction Split avec quelques entrées, puis nous le comparons au résultat que nous attendions.

1. Couverture du code

La question qui suit est : quelle est la couverture de test de ce paquet ? Heureusement, l’outil go tool intègre l’analyse de la couverture de test. On peut l’appeler comme cela :

% go test -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok      split   0.010s

Cela nous indique que nous avons une couverture de test de 100%, ce qui n’est pas vraiment surprenant, il n’y a qu’une seule branche dans ce code.

Si nous voulons plus d’information dans le rapport de couverture, go tool propose plusieurs options. Nous pouvons utiliser go tool cover -func pour décomposer la couverture par fonction :

% go tool cover -func=c.out
split/split.go:8:       Split          100.0%
total:                  (statements)   100.0%

Ce qui n’est pas très excitant car nous n’avons qu’une seule fonction dans ce paquet, mais je suis sûr que vous trouverez d’autres paquets intéressants à tester.

2. Mettez-y un peu de .bashrc.

Cette suite de commandes est si utile pour moi que j’ai un alias shell qui exécute la couverture de test et le rapport en une seule commande :

cover () {
    local t=$(mktemp -t cover)
    go test $COVERFLAGS -coverprofile=$t $@ \
        && go tool cover -func=$t \
        && unlink $t
}

3. Aller au-delà de 100% de couverture.

Donc, nous avons écrit un cas de test, obtenu une couverture de 100%, mais ce n’est pas vraiment la conclusion de cette histoire. Nous avons une bonne couverture des branches, mais nous devons probablement tester certaines des conditions aux limites. Par exemple, que se passe-t-il si nous essayons de le séparer par des virgules ?

func TestSplitWrongSep(t *testing.T) {
    got := Split("a/b/c", ",")
    want := []string{"a/b/c"}
    if !reflect.DeepEqual(want, got) {
        t.Fatalf("expected: %v, got: %v", want, got)
    }
}

Ou, que se passe-t-il s’il n’y a pas de séparateurs dans la chaîne source ?

func TestSplitNoSep(t *testing.T) {
    got := Split("abc", "/")
    want := []string{"abc"}
    if !reflect.DeepEqual(want, got) {
        t.Fatalf("expected: %v, got: %v", want, got)
    }
}

Nous commençons à construire un ensemble de cas de tests qui valident les conditions aux limites. C’est une bonne chose.

4. Présentation des tests structurés en tableau

Cependant, il y a beaucoup de dédoublement de code dans nos tests. Pour chaque cas de test, seules l’entrée, la sortie attendue et le nom du cas de test changent. Tout le reste, c’est de la poudre aux yeux. Nous aimerions configurer toutes les entrées et les sorties attendues dans un seul test. C’est le moment idéal pour introduire les tests en tableau.

func TestSplit(t *testing.T) {
    type test struct {
        input string
        sep   string
        want  []string
    }

    tests := []test{
        {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        {input: "abc", sep: "/", want: []string{"abc"}},
    }

    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("expected: %v, got: %v", tc.want, got)
        }
    }
}

Nous déclarons une structure pour stocker nos entrées et nos sorties de test attendues. Voici notre tableau. La structure de tests est généralement une déclaration locale parce que nous voulons réutiliser ce nom pour d’autres tests dans ce paquet.

En fait, nous n’avons même pas besoin de donner un nom au type, nous pouvons utiliser une structure anonyme pour réduire le test comme ceci :

func TestSplit(t *testing.T) {
    tests := []struct {
        input string
        sep   string
        want  []string
    }{
        {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        {input: "abc", sep: "/", want: []string{"abc"}},
    }

    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("expected: %v, got: %v", tc.want, got)
        }
    }
}

Maintenant, l’ajout d’un nouveau test est une affaire simple ; ajoutez simplement une nouvelle ligne dans la structure tests. Par exemple, que se passera-t-il si notre entrée a un séparateur en fin de chaine ?

{input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
{input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
{input: "abc", sep: "/", want: []string{"abc"}},
{input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}}, // trailing sep

Mais, quand on lance go test, on obtient

% go test
--- FAIL: TestSplit (0.00s)
    split_test.go:24: expected: [a b c], got: [a b c ]

Mis à part l’échec du test, il y a quelques problèmes dont il faut parler.

Le premier est qu’en réécrivant chaque test d’une fonction à chaque ligne d’une table, nous avons perdu le nom du test qui a échoué. Nous avons ajouté un commentaire dans le fichier de test pour appeler ce cas, mais nous n’avons pas accès à ce commentaire dans la sortie go test.

Il y a plusieurs façons de résoudre ce problème. Vous verrez un mélange de styles utilisés dans les bases de code Go parce que l’idiome des tests en tableau évolue à mesure que les gens expérimentent avec cette manière de faire.

5. Énumération de cas de test

Comme les tests sont stockés dans une slice, nous pouvons afficher l’index du scénario de test dans le message de dysfonctionnement :

func TestSplit(t *testing.T) {
    tests := []struct {
        input string
        sep . string
        want  []string
    }{
        {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        {input: "abc", sep: "/", want: []string{"abc"}},
        {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
    }

    for i, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("test %d: expected: %v, got: %v", i+1, tc.want, got)
        }
    }
}

Maintenant, quand on lance go test, on obtient ceci

% go test
--- FAIL: TestSplit (0.00s)
    split_test.go:24: test 4: expected: [a b c], got: [a b c ]

Ce qui est un peu mieux. Nous savons maintenant que le quatrième test échoue, bien que nous devions faire un peu d’esbroufe parce que l’indexation par slice - et l’itération de plage - commence par zéro. Cela nécessite une cohérence entre vos cas de test ; si certains utilisent le reporting avec des numéros commençant par zéro et d’autres commençant par un, cela va créer de la confusion. Et, si la liste des cas de test est longue, il pourrait être difficile de compter les accolades pour déterminer exactement quel ligne constitue le cas de test numéro quatre.

6. Donnez des noms à vos scénarios de test

Un autre modèle courant consiste à inclure un champ de nom dans le tableau.

func TestSplit(t *testing.T) {
    tests := []struct {
        name  string
        input string
        sep   string
        want  []string
    }{
        {name: "simple", input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        {name: "wrong sep", input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        {name: "no sep", input: "abc", sep: "/", want: []string{"abc"}},
        {name: "trailing sep", input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
    }

    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("%s: expected: %v, got: %v", tc.name, tc.want, got)
        }
    }
}

Maintenant, lorsque le test échoue, nous avons un nom qui décrit ce que le test faisait. Nous n’avons plus besoin d’essayer de le découvrir à partir de la sortie - nous avons aussi maintenant une chaîne de caractères sur laquelle nous pouvons faire une recherche.

% go test
--- FAIL: TestSplit (0.00s)
    split_test.go:25: trailing sep: expected: [a b c], got: [a b c ]

Nous pouvons encore mieux nous en débarrasser à l’aide d’une syntaxe littérale de map :

func TestSplit(t *testing.T) {
    tests := map[string]struct {
        input string
        sep   string
        want  []string
    }{
        "simple":       {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        "wrong sep":    {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        "no sep":       {input: "abc", sep: "/", want: []string{"abc"}},
        "trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
    }

    for name, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("%s: expected: %v, got: %v", name, tc.want, got)
        }
    }
}

En utilisant une syntaxe littérale de map, nous définissons nos cas de test non pas comme une slice de structs, mais comme une map de noms de tests. Il y a aussi un avantage secondaire à utiliser une map qui va potentiellement améliorer l’utilité de nos tests.

L’ordre d’itération de la map est indéfini à 1. Cela signifie que chaque fois que nous lançons go test, nos tests vont être potentiellement exécutés dans un ordre différent.

C’est super utile pour repérer les conditions où le test passe quand il est exécuté dans l’ordre des instructions, mais pas autrement. Si vous constatez que vos tests ne passent pas dans un ordre différent, vous avez probablement un état global qui est modifié par un test et des tests ultérieurs qui dépendront ensuite de cette modification.

7. Introduction des sous-tests

Avant de réparer l’échec du test, il y a quelques autres problèmes à résoudre dans notre liste de test structuré en tableau.

La première, c’est qu’on appelle t.Fatalf en cas d’échec d’un des tests. Cela signifie qu’après le premier cas d’échec, nous cessons de tester les autres cas. Parce que les cas de test sont exécutés dans un ordre indéfini, s’il y a un test en échec, il serait bon de savoir si c’était le seul échec ou seulement le premier.

Le package testing le ferait pour nous si nous nous efforcions d’écrire chaque cas de test dans sa fonction propre, mais c’est assez verbeux. La bonne nouvelle est que depuis Go 1.7, une nouvelle fonctionnalité a été ajoutée qui nous permet de le faire facilement pour les tests structurés en tableau. C’est ce qu’on appelle des sous-tests.

func TestSplit(t *testing.T) {
    tests := map[string]struct {
        input string
        sep   string
        want  []string
    }{
        "simple":       {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        "wrong sep":    {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        "no sep":       {input: "abc", sep: "/", want: []string{"abc"}},
        "trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
    }

    for name, tc := range tests {
        t.Run(name, func(t *testing.T) {
            got := Split(tc.input, tc.sep)
            if !reflect.DeepEqual(tc.want, got) {
                t.Fatalf("expected: %v, got: %v", tc.want, got)
            }
        })
    }
}

Comme chaque sous-test a maintenant un nom, ce nom est automatiquement affiché lors de chaque test.

% go test
--- FAIL: TestSplit (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
        split_test.go:25: expected: [a b c], got: [a b c ]

Chaque sous-test est sa propre fonction anonyme, nous pouvons donc utiliser t.Fatalf, t.Skipf, et tous les autres testing.Thelpers, tout en conservant la compacité d’un test structuré en tableau.

8. Les sous-cas de test individuels peuvent être exécutés directement.

Comme les sous-tests ont un nom, vous pouvez exécuter une sélection de sous-tests par nom en utilisant le drapeau go test -run.

% go test -run=.*/trailing -v
=== RUN   TestSplit
=== RUN   TestSplit/trailing_sep
--- FAIL: TestSplit (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
        split_test.go:25: expected: [a b c], got: [a b c ]

9. Comparer ce que nous avons obtenu avec ce que nous voulions

Maintenant, nous sommes prêts à réparer la suite de test. Regardons l’erreur.

--- FAIL: TestSplit (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
        split_test.go:25: expected: [a b c], got: [a b c ]

Pouvez-vous diagnostiquer le problème ? Il est clair que les slices sont différentes, c’est ce qui pertube reflect.DeepEqual. Mais repérer la différence réelle n’est pas facile, vous devez repérer cet espace supplémentaire après c. Cela peut paraître simple dans cet exemple simple, mais c’est autre chose lorsque vous comparez deux structures gRPC complexes profondément imbriquées.

Nous pouvons améliorer la sortie si nous passons à la syntaxe %#v pour voir la valeur comme une déclaration Go :

got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
    t.Fatalf("expected: %#v, got: %#v", tc.want, got)
}

Maintenant, lorsque nous effectuons notre test, il est clair que le problème est qu’il y a un élément vide supplémentaire dans la slice.

% go test
--- FAIL: TestSplit (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
        split_test.go:25: expected: []string{"a", "b", "c"}, got: []string{"a", "b", "c", ""}

Mais avant d’aller corriger notre test en erreur, je voudrais parler un peu plus du choix de la bonne façon de présenter les échecs de test. Notre fonction Split est simple, elle prend une string primitive et retourne une slice de strings, mais si elle fonctionnait avec des structs, ou pire, des pointeurs vers des structs ?

Voici un exemple où %#v ne fonctionne pas aussi bien :

func main() {
    type T struct {
        I int
    }
    x := []*T{{1}, {2}, {3}}
    y := []*T{{1}, {2}, {4}}
    fmt.Printf("%v %v\n", x, y)
    fmt.Printf("%#v %#v\n", x, y)
}

Le premier fmt.Printf affiche la slice inutilisable mais attendue de slice d’adresses ;[0xc000096000 0xc0000960096008 0xc000096010][0xc000096018 0xc000096020 0xc00000096028]. Cependant, notre version avec %#v ne s’en tire pas mieux, en affichant une slice d’adresses sur *main.T;[]*main.T{(*main.T)(0xc000096000), (*main.T)(0xc000096008), (*main.T)(0xc00009600010)} []*main.T{(*main.T)(0xc00000096018), (*main.T)(0xc00000096020), (*main.T)(0xc000096028)}

En raison des limitations dans l’utilisation de n’importe quel verbe fmt.Printf, je tiens à vous présenter la bibliothèque go-cmp de Google.

Le but de la bibliothèque cmp est spécifiquement de comparer deux valeurs. C’est similaire à reflect.DeepEqual, mais il a plus de possibilités. En utilisant le paquet cmp, vous pouvez, bien sûr, écrire :

func main() {
    type T struct {
        I int
    }
    x := []*T{{1}, {2}, {3}}
    y := []*T{{1}, {2}, {4}}
    fmt.Println(cmp.Equal(x, y)) // false
}

Mais bien plus utile pour nous avec notre fonction de test est la fonction cmp.diff qui va produire une description textuelle de ce qui est différent entre les deux valeurs, de manière récursive.

func main() {
    type T struct {
        I int
    }
    x := []*T{{1}, {2}, {3}}
    y := []*T{{1}, {2}, {4}}
    diff := cmp.Diff(x, y)
    fmt.Printf(diff)
}

Qui au lieu de ça produit :

% go run
{[]*main.T}[2].I:
         -: 3
         +: 4

Nous avertissant qu’à l’élément 2 de la slice T, le champs I devait avoir 3, mais qu’il a en fait 4.

En mettant tout cela ensemble, nous avons notre test go-cmp structuré en tableau.

func TestSplit(t *testing.T) {
    tests := map[string]struct {
        input string
        sep   string
        want  []string
    }{
        "simple":       {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        "wrong sep":    {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        "no sep":       {input: "abc", sep: "/", want: []string{"abc"}},
        "trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
    }

    for name, tc := range tests {
        t.Run(name, func(t *testing.T) {
            got := Split(tc.input, tc.sep)
            diff := cmp.Diff(tc.want, got)
            if diff != "" {
                t.Fatalf(diff)
            }
        })
    }
}

Le lancement du test donne

% go test
--- FAIL: TestSplit (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
        split_test.go:27: {[]string}[?->3]:
                -: <non-existent>
                +: ""
FAIL
exit status 1
FAIL    split   0.006s

En utilisant cmp.diff, notre batterie de test ne se contente pas de nous dire que ce que nous avons obtenu et ce que nous voulions étaient différents. Notre test nous dit que les chaînes sont de longueurs différentes, le troisième index dans le dispositif ne devrait pas exister, mais la sortie réelle nous avons une chaîne vide, "". A partir de là, la correction de cette erreur de test est simple.

10. Articles connexes :