Go to Hackademy website

Tests http en Go

Théo Delaune

Posté par Théo Delaune dans les catégories back

Nous allons aborder ensemble, les tests sur des appels HTTP en Go.

La mise en place de tests en Go peut paraître simple de premier abord, mais peut vite devenir un challenge technique lorsque l’on commence à approcher des choses moins triviales.

Sur une application interne, j’ai voulu tester des fonctions utilisant des requêtes HTTP. Plusieurs problématiques sont remontées : est-ce qu’il faut mocker un serveur HTTP ? Mon code est-il assez modulaire ? etc…

Nous allons créer une application cliente permettant d’ajouter et lister des voitures sur notre API issue de notre article Créer une API simpliste en Go.

Client

Nous mettons en place une application cliente, dont le but est de récupérer et de créer les voitures sur une API distantes, par la création de ces deux fonctions :

  • AddCar pour ajouter une voiture sur notre API.
  • GetAllCars pour lister toutes les voitures présentes sur l’API.

Ces deux fontions vont faire appel à des requêtes HTTP par l’intermédiaire du paquet http du langage Go.

Commençons par définir ensemble une structure simplifiée pour représenter une voiture :

type Car struct {
  Manufacturer string `json:"manufacturer"`
  Design       string `json:"design"`
  Doors        uint8  `json:"doors"`
}

fonction GetAllCars()

Afin de récupérer la liste des voitures disponibles sur l’API, nous utilisons le paquet http pour éxécuter une requête GET sur notre endpoint /cars de l’API.

Cette fonction parse la réponse JSON de l’appel HTTP et la convertit en une liste d’objets Car.

func GetAllCars() []Car {

  resp, err := http.Get("http://localhost:8080/cars")
  if err != nil {
    panic(err)
  }

  defer resp.Body.Close()

  body, err := ioutil.ReadAll(resp.Body)

  var cars []Car

  err = json.Unmarshal(body, &cars)

  if err != nil {
    panic(err)
  }

  return cars
}

fonction AddCar(car *Car)

Dans le cas de l’ajout d’une voiture, nous devons convertir notre objet Car en un objet de type JSON. Nous passons ensuite cet objet JSON dans le corps de notre requête POST sur le endpoint /cars.

Notre API nous retourne un objet JSON contenant la voiture ajoutée. Nous récupérons ce retour et le convertissons en un objet Car.

func AddCar(car *Car) Car {
  encodedJson, err := json.Marshal(car)

  if err != nil {
    panic(err)
  }
  resp, err := http.Post("http://localhost:8080/cars", "application/json", bytes.NewBuffer(encodedJson))

  if err != nil {
    panic(err)
  }

  defer resp.Body.Close()

  body, err := ioutil.ReadAll(resp.Body)

  var returnedCar Car

  err = json.Unmarshal(body, &returnedCar)

  if err != nil {
    panic(err)
  }

  return returnedCar
}

fonction main()

Nous pouvons maintenant appeler ces deux fonctions au sein de notre fonction main.

func main() {
  car := &Car{Manufacturer: "renault", Design: "r5", Doors: 3}

  fmt.Println(AddCar(car))

  fmt.Println(GetAllCars())
}

Au lancement de notre programme, une voiture est ajoutée sur notre API, puis nous listons toutes les voitures depuis l’API.

Veillez à bien avoir lancé le serveur de l’API avant toute chose.

Problématique

La construction de ces deux méthodes rend difficile leurs tests, car les appels HTTP se trouvent à l’intérieur des deux fonctions.

Cela implique de devoir lancer un serveur mocké pour remplacer l’API.

Ce mock ne permet pas de pouvoir tester le traitement de nos fonctions. Comme par exemple vérifier que les paramètres pour créer notre objet sont corrects.

Ce problème est dû à l’architecture de nos fonctions, qui ne sont pas modulaires. Nous allons remédier à cela en architecturant nos fonctions par la définition de tests unitaires.

Tests

Nous allons ici mettre en place des tests pour définir l’architecture des fonctions de notre application.

fonction TestAddCarRequest(t *testing.T)

Vérifions que notre fonction prend en argument un objet de type Car et que la requête comprend bien notre Car sous forme de JSON dans le body de la requête.

func TestAddCarRequest(t *testing.T) {
  var carTests = []struct {
    Car  *Car
    Json string
  }{
    {&Car{}, `{"manufacturer":"","design":"","doors":0}`},
    {&Car{Manufacturer: "renault", Design: "r5", Doors: 3}, `{"manufacturer":"renault","design":"r5","doors":3}`},
  }

  for _, tt := range carTests {
    request, err := AddCarRequest(tt.Car, "urlapifake")

    defer request.Body.Close()
    json, _ := ioutil.ReadAll(request.Body)

    if tt.Json != string(json) {
      t.Errorf("AddCarRequest: expected: %s, actual: %s", tt.Json, string(json))
    }
    if err != nil {
      t.Errorf("AddCarRequest: error: %s", err)
    }
  }
}

fonction TestParseAddCarResponse(t *testing.T)

Nous testons maintenant la fonction AddCarResponse qui gère le retour de notre requête API.

Cette fonction prend en argument un objet de type *http.Response et nous vérifions qu’elle retourne un objet de type Car.

func TestParseAddCarResponse(t *testing.T) {
  var carTests = []struct {
    Car  *Car
    Json string
  }{
    {&Car{}, `{"manufacturer":"","design":"","doors":0}`},
    {&Car{Manufacturer: "renault", Design: "r5", Doors: 3}, `{"manufacturer":"renault","design":"r5","doors":3}`},
  }

  for _, tt := range carTests {
    resp := &http.Response{Body: ioutil.NopCloser(strings.NewReader(tt.Json))}

    car, err := ParseAddCarResponse(resp)

    if !reflect.DeepEqual(tt.Car, car) {
      t.Errorf("ParseAddCarResponse: expected: %v, actual: %v", tt.Car, car)
    }

    if err != nil {
      t.Errorf("ParseAddCarResponse: error: %s", err)
    }
  }
}

Vous pouvez retrouver les tests concernant notre fonction de récupération de toutes les voitures sur le dépôt GitHub.

Refactoring de l’application suite aux tests

Suite à ces tests, nous pouvons maintenant écrire nos fonctions.

fonction AddCarRequest(car *Car, url string)

AddCarRequest, prend en paramètre un objet de type Car ainsi que l’URL de l’API au format string et nous retourne un objet http.Request.

Le but de cette fonction est de créer la requête sans l’envoyer, la voiture de type Car passée en argument doit être convertie dans le body de la requête au format JSON.

Nous spécifions le content-type en l’ajoutant dans les headers de la requête.

func AddCarRequest(car *Car, url string) (*http.Request, error) {
  encodedJson, err := json.Marshal(car)

  if err != nil {
    return nil, err
  }
  req, err := http.NewRequest("POST", "http://localhost:8080/cars", bytes.NewBuffer(encodedJson))
  req.Header.Set("Content-Type", "application/json")

  return req, err
}

Notre appel HTTP sur notre requête sera effectué en dehors des fonctions actuelles.

fonction ParseAddCarResponse(resp *http.Response)

Nous récupérons une réponse de type http.Response suite à l’éxécution de la requête issue de la fonction AddCar. ParseAddCarResponse, nous permet d’extraire la voiture du corps JSON de la réponse et de la convertir en un objet Car.

func ParseAddCarResponse(resp *http.Response) (*Car, error) {
  var returnedCar Car

  defer resp.Body.Close()

  body, err := ioutil.ReadAll(resp.Body)

  if err != nil {
    return nil, err
  }

  err = json.Unmarshal(body, &returnedCar)

  return &returnedCar, err
}

fonction AddCar(car *Car, client *http.Client, url string)

Nous pouvons maintenant utiliser ces deux fonctions dans notre fonction AddCar, notre code à l’intérieur de cette fonction est maintenant testé.

Le seul point non testé, est l’appel à notre requête client.Do, cette fonction étant un paquet core de Go, elle est déjà testée par le langage.

func AddCar(car *Car, client *http.Client, url string) (*Car, error) {
  req, err := AddCarRequest(car, url)

  if err != nil {
    return nil, err
  }

  resp, err := client.Do(req)

  if err != nil {
    return nil, err
  }

  returnedCar, err := ParseAddCarResponse(resp)

  return returnedCar, err
}

Allons plus loin

Dans certains cas, nous voulons nous assurer que notre fonction agrégeant nos fonctions testées soit testée elle-même.

Nous n’avons ici, pas d’autres solutions que de mocker un serveur HTTP. Le paquet httptest nous permet justement cela.

Nous créons un serveur HTTP qui retourne un contenu défini, en l’occurence un objet de type JSON contenant notre voiture.

Cela nous impose de passer l’URL de notre ressource API en paramètre à notre fonction.

Notre serveur créé retourne dans tous les cas le contenu de notre handler returnJsonCarHandler.

Il nous suffit d’utiliser l’URL du serveur de tests dans notre fonction, pour y avoir accès et l’utiliser.

func TestAddCar(t *testing.T) {
  var returnJsonCarHandler = func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, `{"manufacturer":"citroen", "design":"ds3", "door":0}`)
  }

  server := httptest.NewServer(http.HandlerFunc(returnJsonCarHandler))
  defer server.Close()

  httpClient := &http.Client{}

  car, _ := AddCar(&Car{}, httpClient, server.URL)

  if car.Manufacturer != "citroen" || car.Design != "ds3" {
    t.Errorf("AddCar: %v && %s", car, server.URL)
  }
}

Des améliorations ?

L’exemple présenté au sein de cet article peut et doit être amélioré.

Quelques pistes :

  • Utiliser une struct contenant le http.Client et l’URL du serveur API pour éviter de passer l’URL en argument de chaque fonction.
  • Extraire la création de notre serveur mocké au sein d’une fonction, pour pouvoir l’utiliser sur plusieurs tests sans devoir le redéfinir pour chaque test.
  • Écrire un test contenant un serveur HTTP mocké pour la fonction GetAllCars.
  • Gérer les codes de retours HTTP lors d’un appel API.

Conclusion

Nous avons pu découvrir et mettre en pratique les tests sur des fonctions utilisant des requêtes HTTP en Go.

Je vous invite à refactorer ces tests et à les améliorer pour pouvoir les adapter sur vos projets.

Les sources de cet article sont disponibles sur GitHub.

L’équipe Synbioz.

Libres d’être ensemble.

Articles connexes

Une brève histoire d'Elixir et Erlang/OTP

31/01/2019

Je développe depuis plusieurs années en Ruby. Depuis mon arrivée chez Synbioz, j’expérimente en plus avec Elixir de façon assez naturelle. En quoi Elixir est-il différent, me demanderez-vous ? Pour...

Écrire une thread pool en Ruby

10/01/2019

Pouvoir exécuter plusieurs tâches en parallèle, que ce soit dans un script ou une application, peut être vraiment très utile, surtout dans le cas où le traitement de ces tâches peut être très long....

Translation temporelle

31/05/2018

Cette semaine, je me suis essayé à un nouveau format d’article qui se présente sous la forme d’une nouvelle de Science-Fiction. Je tiens en passant à remercier Valentin pour ses illustrations....

Authentifier l'accès à vos ressources avec Dragonfly

11/05/2017

Pour ceux qui ne connaissent pas Dragonfly, c’est une application Rack qui peut être utilisée seule ou via un middleware. Le fait que ce soit une application Rack la rend compatible avec toutes les...