Test de votre application Go : démarrez du bon pied
Publié: 2022-03-11Lorsque vous apprenez quelque chose de nouveau, il est important d'avoir un nouvel état d'esprit.
Si vous êtes relativement nouveau sur Go et que vous venez de langages tels que JavaScript ou Ruby, vous êtes probablement habitué à utiliser des frameworks existants qui vous aident à vous moquer, à affirmer et à effectuer d'autres tests magiques.
Éradiquez maintenant l'idée de dépendre de dépendances ou de frameworks externes ! Les tests ont été le premier obstacle sur lequel j'ai trébuché lors de l'apprentissage de ce langage de programmation remarquable il y a quelques années, à une époque où il y avait beaucoup moins de ressources disponibles.
Je sais maintenant que tester le succès dans Go signifie voyager léger sur les dépendances (comme pour tout ce qui concerne Go), s'appuyer au minimum sur des bibliothèques externes et écrire un bon code réutilisable. Cette présentation des expériences de Blake Mizerany en s'aventurant avec des bibliothèques de test tierces est un bon début pour ajuster votre état d'esprit. Vous verrez de bons arguments sur l'utilisation de bibliothèques et de frameworks externes par rapport à la manière de procéder.
Il peut sembler contre-intuitif de créer votre propre cadre de test et vos propres concepts moqueurs, mais c'est plus facile qu'on ne le pense et c'est un bon point de départ pour apprendre le langage. De plus, contrairement à l'époque où j'apprenais, vous avez cet article pour vous guider à travers des scénarios de test courants ainsi que pour introduire des techniques que je considère comme les meilleures pratiques pour tester efficacement et garder le code propre.
Test de tableau en Go
L'unité de test de base - connue sous le nom de "test unitaire" - peut être n'importe quel composant d'un programme dans sa forme la plus simple qui prend une entrée et renvoie une sortie. Examinons une fonction simple pour laquelle nous aimerions écrire des tests. C'est loin d'être parfait ou complet, mais c'est assez bon à des fins de démonstration :
avg.go
func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }
La fonction ci-dessus, func Avg(nos ...int)
, renvoie soit zéro, soit la moyenne entière d'une série de nombres qui lui sont donnés. Maintenant, écrivons un test pour cela.
Dans Go, il est recommandé de nommer un fichier de test avec le même nom que le fichier qui contient le code testé, avec le suffixe ajouté _test
. Par exemple, le code ci-dessus se trouve dans un fichier nommé avg.go
, donc notre fichier de test s'appellera avg_test.go
.
Notez que ces exemples ne sont que des extraits de fichiers réels, car la définition du package et les importations ont été omises pour des raisons de simplicité.
Voici un test pour la fonction Avg
:
avg__test.go
func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }
Il y a plusieurs choses à noter à propos de la définition de la fonction :
- Tout d'abord, le préfixe 'Test' sur le nom de la fonction de test. Ceci est nécessaire pour que l'outil le considère comme un test valide.
- La dernière partie du nom de la fonction est généralement le nom de la fonction ou de la méthode testée, dans ce cas
Avg
. - Nous devons également transmettre la structure de test appelée
testing.T
, qui permet de contrôler le flux du test. Pour plus de détails sur cette API, veuillez visiter la page de documentation.
Parlons maintenant de la forme sous laquelle l'exemple est écrit. Une suite de tests (une série de tests) est exécutée via la fonction Avg()
, et chaque test contient une entrée spécifique et la sortie attendue. Dans notre cas, chaque test envoie une tranche d'entiers ( Nos
) et attend une valeur de retour spécifique ( Result
).
Moquerie de l'interface Golang
L'une des fonctionnalités les plus importantes et les plus puissantes du langage Go s'appelle une interface. Outre la puissance et la flexibilité que nous procure l'interfaçage lors de l'architecture de nos programmes, l'interfaçage nous offre également d'incroyables opportunités de découpler nos composants et de les tester minutieusement à leur point de rencontre.
Prenons un scénario imaginaire où nous devons lire les N premiers octets d'un io.Reader et les renvoyer sous forme de chaîne. Cela ressemblerait à ceci :
readn.go
// readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }
De toute évidence, la principale chose à tester est que la fonction readN
, lorsqu'elle reçoit diverses entrées, renvoie la sortie correcte. Cela peut être fait avec des tests de table. Mais il y a deux autres aspects non triviaux que nous devrions couvrir, qui vérifient que :
-
r.Read
est appelé avec un buffer de taille n. -
r.Read
renvoie une erreur si une est renvoyée.
Afin de connaître la taille du tampon qui est passé à r.Read
, ainsi que de prendre le contrôle de l'erreur qu'il renvoie, nous devons nous moquer du r
passé à readN
. Si on regarde la documentation Go sur le type Reader, on voit à quoi ressemble io.Reader
:
type Reader interface { Read(p []byte) (n int, err error) }
Cela semble plutôt facile. Tout ce que nous avons à faire pour satisfaire io.Reader
est d'avoir notre propre méthode de Read
fictive. Ainsi, notre ReaderMock
peut être le suivant :
type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }
Analysons un peu le code ci-dessus. Toute instance de ReaderMock
satisfait clairement l'interface io.Reader
car elle implémente la méthode Read
nécessaire. Notre maquette contient également le champ ReadMock
, nous permettant de définir le comportement exact de la méthode simulée, ce qui nous permet d'instancier dynamiquement tout ce dont nous avons besoin très facilement.
Une excellente astuce sans mémoire pour s'assurer que l'interface est satisfaite au moment de l'exécution consiste à insérer ce qui suit dans notre code :
var _ io.Reader = (*MockReader)(nil)
Cela vérifie l'assertion mais n'alloue rien, ce qui nous permet de nous assurer que l'interface est correctement implémentée au moment de la compilation, avant que le programme ne se heurte à une fonctionnalité l'utilisant. Une astuce facultative, mais utile.
Continuons, écrivons notre premier test, dans lequel r.Read
est appelé avec un tampon de taille n
. Pour ce faire, nous utilisons notre ReaderMock
comme suit :
func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }
Comme vous pouvez le voir ci-dessus, nous avons défini le comportement de la fonction Read
de notre « faux » io.Reader
avec une variable de portée, qui peut être utilisée ultérieurement pour affirmer la validité de notre test. Assez facile.
Examinons le deuxième scénario que nous devons tester, qui nous oblige à simuler Read
pour renvoyer une erreur :
func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }
Dans les tests ci-dessus, tout appel à mr.Read
(notre lecteur simulé) renverra l'erreur définie, il est donc prudent de supposer que le bon fonctionnement de readN
fera de même.
Fonction se moquant avec Go
Ce n'est pas souvent que nous avons besoin de nous moquer d'une fonction, car nous avons tendance à utiliser des structures et des interfaces à la place. Celles-ci sont plus faciles à contrôler, mais nous pouvons parfois nous heurter à cette nécessité, et je constate fréquemment une confusion autour du sujet. Certaines personnes ont même demandé comment se moquer de choses comme log.Println
. Bien qu'il soit rarement nécessaire de tester les entrées fournies à log.Println
, nous profiterons de cette occasion pour le démontrer.

Considérez cette simple instruction if
ci-dessous qui enregistre la sortie en fonction de la valeur de n
:
func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }
Dans l'exemple ci-dessus, nous supposons le scénario ridicule où nous testons spécifiquement que log.Println
est appelé avec les valeurs correctes. Pour nous moquer de cette fonction, nous devons d'abord l'envelopper dans la nôtre :
var show = func(v ...interface{}) { log.Println(v...) }
Déclarer la fonction de cette manière - en tant que variable - nous permet de l'écraser dans nos tests et de lui attribuer le comportement que nous voulons. Implicitement, les lignes faisant référence à log.Println
sont remplacées par show
, donc notre programme devient :
func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }
Maintenant on peut tester :
func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }
Notre plat à emporter ne devrait pas être 'mock log.Println
', mais que dans ces scénarios très occasionnels où nous devons nous moquer d'une fonction au niveau du package pour des raisons légitimes, la seule façon de le faire (pour autant que je sache) est en la déclarant en tant que variable au niveau du package afin que nous puissions prendre le contrôle de sa valeur.
Cependant, si jamais nous avons besoin de nous moquer de choses comme log.Println
, une solution beaucoup plus élégante peut être écrite si nous devions utiliser un enregistreur personnalisé.
Tests de rendu du modèle Go
Un autre scénario assez courant consiste à tester que la sortie d'un modèle rendu est conforme aux attentes. Considérons une requête GET à http://localhost:3999/welcome?name=Frank
, qui renvoie le corps suivant :
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
Au cas où ce n'était pas assez évident maintenant, ce n'est pas une coïncidence si le name
du paramètre de requête correspond au contenu de l' span
classée comme "nom". Dans ce cas, le test évident consisterait à vérifier que cela se produit correctement à chaque fois sur plusieurs sorties. J'ai trouvé la bibliothèque GoQuery extrêmement utile ici.
Nous pouvons maintenant écrire notre test de cette manière :
welcome__test.go
func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }
Tout d'abord, nous vérifions que le code de réponse était 200/OK avant de continuer.
Je pense qu'il n'est pas exagéré de supposer que le reste de l'extrait de code ci-dessus est explicite : nous récupérons l'URL à l'aide du package http
et créons un nouveau document compatible goquery à partir de la réponse, que nous utilisons ensuite pour interroger le DOM qui a été retourné. Nous vérifions que le span.name
à l'intérieur h1.header-name
encapsule le texte 'Frank'.
Tester les API JSON
Go est fréquemment utilisé pour écrire des API de quelque sorte que ce soit, donc enfin et surtout, examinons quelques moyens de haut niveau de tester les API JSON.
Considérez si le point de terminaison renvoyait auparavant JSON au lieu de HTML, donc à partir de http://localhost:3999/welcome.json?name=Frank
, nous nous attendrions à ce que le corps de la réponse ressemble à :
{"Salutation": "Hello Frank!"}
L'affirmation de réponses JSON, comme on l'a peut-être déjà deviné, n'est pas très différente de l'affirmation de réponses de modèle, à l'exception du fait que nous n'avons pas besoin de bibliothèques ou de dépendances externes. Les bibliothèques standard de Go sont suffisantes. Voici notre test confirmant que le bon JSON est renvoyé pour les paramètres donnés :
welcome__test.go
func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }
Si autre chose que la structure que nous décodons devait être renvoyée, json.NewDecoder
à la place une erreur et le test échouera. Considérant que la réponse décode avec succès par rapport à la structure, nous vérifions que le contenu du champ est comme prévu - dans notre cas "Hello Frank!".
Configuration et démontage
Tester avec Go est facile, mais il y a un problème avec le test JSON ci-dessus et le test de rendu de modèle avant cela. Ils supposent tous les deux que le serveur est en cours d'exécution, ce qui crée une dépendance non fiable. De plus, ce n'est pas une bonne idée d'aller contre un serveur "live".
Heureusement, Go propose le package httptest pour créer des serveurs de test. Les tests déclenchent leur propre serveur séparé, indépendant de notre serveur principal, et ainsi les tests n'interféreront pas avec la production.
Dans ces cas, il est idéal de créer des fonctions génériques setup
et de teardown
à appeler par tous les tests nécessitant un serveur en cours d'exécution. En suivant ce nouveau modèle plus sûr, nos tests finiraient par ressembler à ceci :
func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }
Notez la référence app.Handler()
. Il s'agit d'une fonction recommandée qui renvoie le http.Handler de l'application, qui peut instancier votre serveur de production ou un serveur de test.
Conclusion
Tester en Go est une excellente occasion d'assumer la perspective extérieure de votre programme et de prendre la place de vos visiteurs ou, dans la plupart des cas, des utilisateurs de votre API. C'est une excellente occasion de vous assurer que vous fournissez à la fois un bon code et une expérience de qualité.
Chaque fois que vous n'êtes pas sûr des fonctionnalités plus complexes de votre code, les tests sont utiles pour vous rassurer et garantissent également que les éléments continueront à bien fonctionner ensemble lors de la modification de parties de systèmes plus importants.
J'espère que cet article vous a été utile, et vous êtes invités à commenter si vous connaissez d'autres astuces de test.