Fonctions simulées dans Go

147

J'apprends Go en codant un petit projet personnel. Même si c'est petit, j'ai décidé de faire des tests unitaires rigoureux pour apprendre de bonnes habitudes sur Go dès le début.

Les tests unitaires triviaux étaient tous très bien, mais je suis maintenant perplexe avec les dépendances; Je veux pouvoir remplacer certains appels de fonction par des faux. Voici un extrait de mon code:

func get_page(url string) string {
    get_dl_slot(url)
    defer free_dl_slot(url)

    resp, err := http.Get(url)
    if err != nil { return "" }
    defer resp.Body.Close()

    contents, err := ioutil.ReadAll(resp.Body)
    if err != nil { return "" }
    return string(contents)
}

func downloader() {
    dl_slots = make(chan bool, DL_SLOT_AMOUNT) // Init the download slot semaphore
    content := get_page(BASE_URL)
    links_regexp := regexp.MustCompile(LIST_LINK_REGEXP)
    matches := links_regexp.FindAllStringSubmatch(content, -1)
    for _, match := range matches{
        go serie_dl(match[1], match[2])
    }
}

J'aimerais pouvoir tester downloader () sans réellement obtenir une page via http - c'est-à-dire en se moquant soit de get_page (plus facile car il ne renvoie que le contenu de la page sous forme de chaîne) soit de http.Get ().

J'ai trouvé ce fil: https://groups.google.com/forum/#!topic/golang-nuts/6AN1E2CJOxI qui semble concerner un problème similaire. Julian Phillips présente sa bibliothèque, Withmock ( http://github.com/qur/withmock ) comme une solution, mais je ne parviens pas à la faire fonctionner. Voici les parties pertinentes de mon code de test, qui est en grande partie un code culte pour moi, pour être honnête:

import (
    "testing"
    "net/http" // mock
    "code.google.com/p/gomock"
)
...
func TestDownloader (t *testing.T) {
    ctrl := gomock.NewController()
    defer ctrl.Finish()
    http.MOCK().SetController(ctrl)
    http.EXPECT().Get(BASE_URL)
    downloader()
    // The rest to be written
}

La sortie de test est la suivante:

ERROR: Failed to install '_et/http': exit status 1
output:
can't load package: package _et/http: found packages http (chunked.go) and main (main_mock.go) in /var/folders/z9/ql_yn5h550s6shtb9c5sggj40000gn/T/withmock570825607/path/src/_et/http

Le Withmock est-il une solution à mon problème de test? Que dois-je faire pour le faire fonctionner?

GolDBranks
la source
Puisque vous plongez dans les tests unitaires de Go, recherchez dans GoConvey un excellent moyen de faire des tests axés sur le comportement ... et un teaser: une interface utilisateur Web mise à jour automatiquement est à venir qui fonctionne également avec les tests natifs de "go test".
Matt

Réponses:

192

Bravo à vous pour avoir pratiqué de bons tests! :)

Personnellement, je n'utilise pas gomock(ni aucun framework moqueur d'ailleurs; se moquer dans Go est très facile sans cela). Je passerais une dépendance à la downloader()fonction en tant que paramètre, ou je créerais downloader()une méthode sur un type, et le type peut contenir la get_pagedépendance:

Méthode 1: passer get_page()comme paramètre dedownloader()

type PageGetter func(url string) string

func downloader(pageGetterFunc PageGetter) {
    // ...
    content := pageGetterFunc(BASE_URL)
    // ...
}

Principale:

func get_page(url string) string { /* ... */ }

func main() {
    downloader(get_page)
}

Tester:

func mock_get_page(url string) string {
    // mock your 'get_page()' function here
}

func TestDownloader(t *testing.T) {
    downloader(mock_get_page)
}

Méthode 2: créer download()une méthode d'un type Downloader:

Si vous ne souhaitez pas passer la dépendance en tant que paramètre, vous pouvez également créer get_page()un membre d'un type et créer download()une méthode de ce type, qui peut ensuite utiliser get_page:

type PageGetter func(url string) string

type Downloader struct {
    get_page PageGetter
}

func NewDownloader(pg PageGetter) *Downloader {
    return &Downloader{get_page: pg}
}

func (d *Downloader) download() {
    //...
    content := d.get_page(BASE_URL)
    //...
}

Principale:

func get_page(url string) string { /* ... */ }

func main() {
    d := NewDownloader(get_page)
    d.download()
}

Tester:

func mock_get_page(url string) string {
    // mock your 'get_page()' function here
}

func TestDownloader() {
    d := NewDownloader(mock_get_page)
    d.download()
}
weberc2
la source
4
Merci beaucoup! Je suis allé avec le deuxième. (il y avait aussi d'autres fonctions que je voulais moquer, donc c'était plus facile de les assigner à une structure) Btw. Je suis un peu amoureux de Go. Surtout ses fonctionnalités de concurrence sont chouettes!
GolDDranks
150
Suis-je le seul à constater que pour des raisons de test, nous devons changer le code principal / la signature des fonctions est terrible?
Thomas
41
@Thomas Je ne sais pas si vous êtes le seul, mais c'est en fait la raison fondamentale du développement piloté par les tests - vos tests guident la façon dont vous écrivez votre code de production. Le code testable est plus modulaire. Dans ce cas, le comportement 'get_page' de l'objet Downloader est maintenant enfichable - nous pouvons changer dynamiquement son implémentation. Vous ne devez changer votre code principal que s'il a été mal écrit en premier lieu.
weberc2
21
@Thomas Je ne comprends pas votre deuxième phrase. TDD génère un meilleur code. Votre code change pour être testable (parce que le code testable est nécessairement modulaire avec des interfaces bien pensées), mais le but principal est d'avoir un meilleur code - avoir des tests automatisés n'est qu'un avantage secondaire impressionnant. Si vous craignez que le code fonctionnel soit modifié simplement pour ajouter des tests après coup, je recommanderais quand même de le changer simplement parce qu'il y a de bonnes chances que quelqu'un veuille un jour lire ce code ou le changer.
weberc2
6
@Thomas bien sûr, si vous écrivez vos tests au fur et à mesure, vous n'aurez pas à faire face à cette énigme.
weberc2
24

Si vous modifiez la définition de votre fonction pour utiliser une variable à la place:

var get_page = func(url string) string {
    ...
}

Vous pouvez le remplacer dans vos tests:

func TestDownloader(t *testing.T) {
    get_page = func(url string) string {
        if url != "expected" {
            t.Fatal("good message")
        }
        return "something"
    }
    downloader()
}

Attention cependant, vos autres tests peuvent échouer s'ils testent la fonctionnalité de la fonction que vous remplacez!

Les auteurs de Go utilisent ce modèle dans la bibliothèque standard Go pour insérer des crochets de test dans le code afin de faciliter les tests:

https://golang.org/src/net/hook.go

https://golang.org/src/net/dial.go#L248

https://golang.org/src/net/dial_test.go#L701

Jake
la source
8
Downvote si vous le souhaitez, il s'agit d'un modèle acceptable pour les petits emballages afin d'éviter le passe-partout associé à l'ID. La variable contenant la fonction est uniquement "globale" par rapport à la portée du package puisqu'elle n'est pas exportée. C'est une option valable, j'ai mentionné l'inconvénient, choisissez votre propre aventure.
Jake
4
Une chose à noter est que la fonction définie de cette manière ne peut pas être récursive.
Ben Sandler
2
Je suis d'accord avec @Jake que cette approche a sa place.
m.kocikowski
11

J'utilise une approche légèrement différente où les méthodes de struct public implémentent des interfaces mais leur logique se limite à simplement envelopper les fonctions privées (non exportées) qui prennent ces interfaces comme paramètres. Cela vous donne la granularité dont vous auriez besoin pour vous moquer de pratiquement toutes les dépendances tout en disposant d'une API propre à utiliser depuis l'extérieur de votre suite de tests.

Pour comprendre cela, il est impératif de comprendre que vous avez accès aux méthodes non exportées dans votre cas de test (c'est-à-dire à partir de vos _test.gofichiers) afin que vous les testiez au lieu de tester celles exportées qui n'ont pas de logique à l'intérieur de l'emballage.

Pour résumer: testez les fonctions non exportées au lieu de tester celles exportées!

Faisons un exemple. Supposons que nous ayons une structure d'API Slack qui a deux méthodes:

  • la SendMessageméthode qui envoie une requête HTTP à un webhook Slack
  • la SendDataSynchronouslyméthode qui a donné une tranche de chaînes les itère et appelle SendMessageà chaque itération

Donc, pour tester SendDataSynchronouslysans faire de requête HTTP à chaque fois, il faudrait se moquer SendMessage, non?

package main

import (
    "fmt"
)

// URI interface
type URI interface {
    GetURL() string
}

// MessageSender interface
type MessageSender interface {
    SendMessage(message string) error
}

// This one is the "object" that our users will call to use this package functionalities
type API struct {
    baseURL  string
    endpoint string
}

// Here we make API implement implicitly the URI interface
func (api *API) GetURL() string {
    return api.baseURL + api.endpoint
}

// Here we make API implement implicitly the MessageSender interface
// Again we're just WRAPPING the sendMessage function here, nothing fancy 
func (api *API) SendMessage(message string) error {
    return sendMessage(api, message)
}

// We want to test this method but it calls SendMessage which makes a real HTTP request!
// Again we're just WRAPPING the sendDataSynchronously function here, nothing fancy
func (api *API) SendDataSynchronously(data []string) error {
    return sendDataSynchronously(api, data)
}

// this would make a real HTTP request
func sendMessage(uri URI, message string) error {
    fmt.Println("This function won't get called because we will mock it")
    return nil
}

// this is the function we want to test :)
func sendDataSynchronously(sender MessageSender, data []string) error {
    for _, text := range data {
        err := sender.SendMessage(text)

        if err != nil {
            return err
        }
    }

    return nil
}

// TEST CASE BELOW

// Here's our mock which just contains some variables that will be filled for running assertions on them later on
type mockedSender struct {
    err      error
    messages []string
}

// We make our mock implement the MessageSender interface so we can test sendDataSynchronously
func (sender *mockedSender) SendMessage(message string) error {
    // let's store all received messages for later assertions
    sender.messages = append(sender.messages, message)

    return sender.err // return error for later assertions
}

func TestSendsAllMessagesSynchronously() {
    mockedMessages := make([]string, 0)
    sender := mockedSender{nil, mockedMessages}

    messagesToSend := []string{"one", "two", "three"}
    err := sendDataSynchronously(&sender, messagesToSend)

    if err == nil {
        fmt.Println("All good here we expect the error to be nil:", err)
    }

    expectedMessages := fmt.Sprintf("%v", messagesToSend)
    actualMessages := fmt.Sprintf("%v", sender.messages)

    if expectedMessages == actualMessages {
        fmt.Println("Actual messages are as expected:", actualMessages)
    }
}

func main() {
    TestSendsAllMessagesSynchronously()
}

Ce que j'aime dans cette approche, c'est qu'en regardant les méthodes non exportées, vous pouvez clairement voir quelles sont les dépendances. En même temps, l'API que vous exportez est beaucoup plus propre et avec moins de paramètres à transmettre puisque la vraie dépendance ici est juste le récepteur parent qui implémente toutes ces interfaces lui-même. Pourtant, chaque fonction ne dépend potentiellement que d'une partie de celle-ci (une, peut-être deux interfaces), ce qui rend les refacteurs beaucoup plus faciles. C'est bien de voir comment votre code est vraiment couplé rien qu'en regardant les signatures des fonctions, je pense que cela fait un outil puissant contre l'odeur de code.

Pour simplifier les choses, je mets tout dans un seul fichier pour vous permettre d'exécuter le code dans le terrain de jeu ici mais je vous suggère également de consulter l'exemple complet sur GitHub, voici le fichier slack.go et ici le slack_test.go .

Et voici le tout :)

Francesco Casula
la source
C'est en fait une approche intéressante et la petite information sur l'accès aux méthodes privées dans le fichier de test est vraiment utile. Cela me rappelle la technique pimpl en C ++. Cependant, je pense qu'il faut dire que tester des fonctions privées est dangereux. Les membres privés sont généralement considérés comme des détails d'implémentation et sont plus susceptibles de changer avec le temps que l'interface publique. Tant que vous ne testez que les wrappers privés autour de l'interface publique, tout devrait bien se passer.
c1moore
Ouais d'une manière générale, je suis d'accord avec vous. Dans ce cas, bien que les corps des méthodes privées soient exactement les mêmes que les corps publics, vous testerez donc exactement la même chose. La seule différence entre les deux réside dans les arguments de la fonction. C'est l'astuce qui vous permet d'injecter n'importe quelle dépendance (moquée ou non) selon vos besoins.
Francesco Casula
Oui je suis d'accord. Je disais simplement que tant que vous vous limitez aux méthodes privées qui englobent ces méthodes publiques, vous devriez être prêt à partir. Ne commencez pas à tester les méthodes privées qui sont des détails d'implémentation.
c1moore
7

Je ferais quelque chose comme,

Principale

var getPage = get_page
func get_page (...

func downloader() {
    dl_slots = make(chan bool, DL_SLOT_AMOUNT) // Init the download slot semaphore
    content := getPage(BASE_URL)
    links_regexp := regexp.MustCompile(LIST_LINK_REGEXP)
    matches := links_regexp.FindAllStringSubmatch(content, -1)
    for _, match := range matches{
        go serie_dl(match[1], match[2])
    }
}

Tester

func TestDownloader (t *testing.T) {
    origGetPage := getPage
    getPage = mock_get_page
    defer func() {getPage = origGatePage}()
    // The rest to be written
}

// define mock_get_page and rest of the codes
func mock_get_page (....

Et _j'éviterais en golang. Mieux vaut utiliser camelCase

Déchue
la source
1
serait-il possible de développer un package qui pourrait le faire pour vous. Je pense quelque chose comme: p := patch(mockGetPage, getPage); defer p.done(). Je suis nouveau, et j'essayais de le faire en utilisant la unsafebibliothèque, mais cela semble impossible à faire dans le cas général.
vitiral
@Fallen c'est presque exactement ma réponse écrite plus d'un an après la mienne.
Jake le
1
1. La seule similitude est la méthode var globale. @Jake 2. Le simple vaut mieux que le complexe. weberc2
Déchu le
1
@fallen Je ne considère pas votre exemple comme plus simple. La transmission d'arguments n'est pas plus complexe que la mutation d'un état global, mais s'appuyer sur un état global introduit de nombreux problèmes qui n'existeraient pas autrement. Par exemple, vous devrez gérer des conditions de course si vous souhaitez paralléliser vos tests.
weberc2
C'est presque la même chose, mais ce n'est pas :). Dans cette réponse, je vois comment attribuer une fonction à une variable et comment cela me permet d'attribuer une implémentation différente pour les tests. Je ne peux pas changer les arguments de la fonction que je teste, c'est donc une bonne solution pour moi. L'alternative est d'utiliser Receiver avec une structure fictive, je ne sais pas encore laquelle est la plus simple.
alexbt
0

Avertissement: cela peut augmenter un peu la taille du fichier exécutable et coûter un peu en termes de performances d'exécution. OMI, ce serait mieux si golang avait une fonctionnalité telle que le décorateur de macro ou de fonction.

Si vous souhaitez simuler des fonctions sans changer son API, le moyen le plus simple est de modifier un peu l'implémentation:

func getPage(url string) string {
  if GetPageMock != nil {
    return GetPageMock()
  }

  // getPage real implementation goes here!
}

func downloader() {
  if GetPageMock != nil {
    return GetPageMock()
  }

  // getPage real implementation goes here!
}

var GetPageMock func(url string) string = nil
var DownloaderMock func() = nil

De cette façon, nous pouvons réellement simuler une fonction parmi les autres. Pour plus de commodité, nous pouvons fournir un tel passe-partout moqueur:

// download.go
func getPage(url string) string {
  if m.GetPageMock != nil {
    return m.GetPageMock()
  }

  // getPage real implementation goes here!
}

func downloader() {
  if m.GetPageMock != nil {
    return m.GetPageMock()
  }

  // getPage real implementation goes here!
}

type MockHandler struct {
  GetPage func(url string) string
  Downloader func()
}

var m *MockHandler = new(MockHandler)

func Mock(handler *MockHandler) {
  m = handler
}

Dans le fichier de test:

// download_test.go
func GetPageMock(url string) string {
  // ...
}

func TestDownloader(t *testing.T) {
  Mock(&MockHandler{
    GetPage: GetPageMock,
  })

  // Test implementation goes here!

  Mock(new(MockHandler)) // Reset mocked functions
}
Tailleur clite
la source
-2

Étant donné que le test unitaire est le domaine de cette question, nous vous recommandons vivement d'utiliser https://github.com/bouk/monkey . Ce package vous permet de simuler un test sans changer votre code source d'origine. Comparé à une autre réponse, c'est plus "non intrusif"。

PRINCIPALE

type AA struct {
 //...
}
func (a *AA) OriginalFunc() {
//...
}

TEST DE SIMULATION

var a *AA

func NewFunc(a *AA) {
 //...
}

monkey.PatchMethod(reflect.TypeOf(a), "OriginalFunc", NewFunc)

Le mauvais côté est:

- Rappelé par Dave.C, cette méthode n'est pas sûre. Ne l'utilisez donc pas en dehors du test unitaire.

- Est-ce que Go est non idiomatique.

Le bon côté est:

++ Est non intrusif. Vous faire faire les choses sans changer le code principal. Comme l'a dit Thomas.

++ Vous faire changer le comportement du package (peut-être fourni par un tiers) avec le moins de code.

Frank Wang
la source
1
Ne faites pas ça. C'est complètement dangereux et peut casser divers composants internes de Go. Sans oublier que ce n'est même pas Go idiomatique à distance.
Dave C
1
@DaveC Je respecte votre expérience sur Golang, mais je soupçonne votre opinion. 1. La sécurité ne signifie pas tout pour le développement de logiciels, la richesse des fonctionnalités et la commodité sont importantes. 2. Golang idiomatique n'est pas Golang, en fait partie. Si un projet est open-source, il est courant que d'autres personnes se moquent de lui. La communauté doit l'encourager au moins pas la supprimer.
Frank Wang
2
La langue s'appelle Go. Par unsafe, je veux dire qu'il peut briser le runtime Go, des choses comme le ramasse-miettes.
Dave C
1
Pour moi, dangereux, c'est cool pour un test unitaire. Si un code de refactorisation avec plus d '«interface» est nécessaire à chaque fois qu'un test unitaire est effectué. Cela me convient davantage que d'utiliser un moyen dangereux de le résoudre.
Frank Wang
1
@DaveC Je suis tout à fait d'accord que c'est une idée terriblement (ma réponse étant la réponse la plus votée et acceptée), mais pour être pédant, je ne pense pas que cela cassera GC parce que le Go GC est conservateur et destiné à gérer des cas comme celui-ci. Je serais cependant heureux d'être corrigé.
weberc2