Go to Hackademy website

Tests en Go

Théo Delaune

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

Aujourd’hui nous allons nous intéresser à la mise en place de tests unitaires dans une application Go.

Pour partir sur un exemple concret nous allons utiliser l’API Go présentée dans l’article Créer une API simpliste en Go.

La mise en place de tests au sein d’une application est primordiale pour pouvoir la maintenir dans la durée mais également pour faire évoluer plus facilement vos applications en évitant les régressions.

Ces tests unitaires vont vous permettre de rendre votre code plus robuste en vérifiant le retour de vos fonctions.

La bibliothèque standard de Go nous fournit de base un package nous permettant de tester notre programme Go, c’est le package testing.

Nous allons dans cet article nous intéresser à la création de tests pour le fichier models/car.go de notre API en Go.

Je vous invite à récupérer les sources de cet article sur GitHub.

Création de notre fichier de tests

Nomenclature

Un fichier de test en Go nécessite une nomenclature spécifique *_test.go et doit être placé dans le même dossier/package que le fichier que nous allons tester.

Dans notre cas c’est le fichier models/car.go, le nom et l’emplacement de notre fichier de test sera alors models/car_test.go.

Cette nomenclature est nécessaire pour que l’éxecutable Go reconnaisse nos fichiers de test parmis les fichiers de code.

Base de notre fichier de test

Le premier package à importer est testing, il est utilisé dans chacune de nos fonctions de test.

package models

import "testing"

Configuration de la base de données pour les tests

Les fonctions que nous allons tester sont responsables de la persistence des struct créées au sein de l’application.

Nous allons modifier sur l’API Go la partie gérant la configuration de la base de données.

La gestion de la base de données se fait dans notre fichier config/database.go.

Pour le bon fonctionnement de nos tests il nous faut une base de données dédiée.

Pour ce faire nous allons ajouter les deux fonctions suivantes à notre application :

// config/database.go

func TestDatabaseInit() {
  connection, err := sql.Open("postgres", "user=theo")
  _, err = connection.Exec("CREATE DATABASE goapi_test")

  connection.Close()

  db, err = sql.Open("postgres", "user=theo dbname=goapi_test")

  if err != nil {
    log.Fatal(err)
  }

  // Create Table cars if not exists
  createCarsTable()
}

func TestDatabaseDestroy() {
  db.Close()

  connection, err := sql.Open("postgres", "user=theo")
  _, err = connection.Exec("DROP DATABASE goapi_test")

  if err != nil {
    log.Fatal(err)
  }
}

TestDatabaseInit, va créer une connexion à PostgreSQL ainsi qu’une base de données goapi_test puis lancer la création de la table cars grâce à la fonction CreateCarsTable.

TestDatabaseDestroy, supprime la base de données de test sur PostgreSQL.

Ces deux fonctions vont nous être utiles avant l’appel à nos tests, cela va permettre d’avoir une base de données saine et vide de toutes données avant le lancement de notre suite de tests.

Utilisation de TestMain

Nos tests étant en relation avec la base de données, il nous faut créer une connexion à cette base avant le lancement des tests.

Go fourni une fonction TestMain(m) permettant de lancer des actions avant la suite de tests.

Cette fonction est prise en compte par le langage et s’exécute dans la goroutine principale main de notre programme.

Nous pouvons maintenant utiliser les deux fonctions créées préalablement dans notre suite de tests.

Nous exécutons avant toute chose la création de la base de données de tests.

Une fois cette action effectuée, nous faisons appel à la fonction m.Run() qui va lancer notre suite de tests et retourner un exit code.

Avant de quitter notre suite de tests nous supprimons la base de données car elle ne nous est plus utile.

Pour terminer nos tests nous devons mettre fin à son exécution en passant à la fonction Exit du package os l’exit code récupéré précédemment.

// models/car_test.go

import (
  "github.com/synbioz/go_api/config"
  "os"
  "testing"
)



func TestMain(m *testing.M) {
  config.TestDatabaseInit()

  ret := m.Run()

  config.TestDatabaseDestroy()
  os.Exit(ret)
}

Reset de la base de données

Pour certains tests, il peut nous être pratique de vider les données de la base. Nous allons utiliser une requête SQL pour supprimer notre table et la recréer.

// models/car_test.go

func ResetTableCars() {
  config.Db().Exec("DROP TABLE cars; CREATE TABLE IF NOT EXISTS cars(id serial,manufacturer varchar(20), design varchar(20), style varchar(20), doors int, created_at timestamp default NULL, updated_at timestamp default NULL, constraint pk primary key(id))")
}

TestNewCar

Rentrons dans le vif du sujet en créant notre premier test concret pour la fonction NewCar.

La définition de notre fonction de test a également une nomenclature.

Chaque fonction de test doit être écrite sous la forme Test***(t *testing.T), où *** représente le nom de la fonction que nous voulons tester.

Nous allons définir une Slice contenant deux objets de type Car. Le premier qui ne contient aucune donnée et le deuxième avec des données d’exemple.

Notre fonction NewCar n’a aucun retour, nous allons ici tester la modification de l’objet une fois qu’il est passé dans NewCar et vérifier qu’il est présent dans la table cars.

Dans un premier temps nous allons vérifier que la date de création et la date de mise à jour correspondent bien à la date actuelle.

Nous formatons nos champs createdAt et updatedAt pour ne pas afficher les secondes et pouvoir rendre les tests corrects.

Si ces cas ne passent pas nous levons une erreur avec la fonction Error ou Errorf de notre variable t.

Dans un deuxième temps nous vérifions que le passage dans NewCar a bien persisté ces deux Car en base de données.

// models/car_test.go

func TestNewCar(t *testing.T) {

  cars := []Car{
    {},
    {Manufacturer: "Volvo", Design: "v40", Style: "urban"},
  }
  for _, c := range cars {
    timeNow := time.Now().Format(time.UnixDate)
    NewCar(&c)

    createdAt := c.CreatedAt.Format(time.UnixDate)
    updatedAt := c.UpdatedAt.Format(time.UnixDate)

    if timeNow != createdAt {
      t.Errorf("Car created_at not have correct DateTime %q != %q", createdAt, timeNow)
    }

    if timeNow != updatedAt {
      t.Errorf("Car updated_at not have correct DateTime %q != %q", createdAt, timeNow)
    }
  }

  rows, _ := config.Db().Query("SELECT * FROM cars")

  var rowsCount int

  for rows.Next() {
    rowsCount += 1
  }

  if rowsCount != 2 {
    t.Errorf("Database does not have 2 records, it has %v records", rowsCount)
  }
}

TestFindCarById

La fonction FindCarById retourne un objet de type Car.

Pour que ce test se lance correctement, il faut avant tout insérer une voiture dans notre base, en utilisant la fonction NewCar.

Comme le passage dans NewCar donne un Id à l’objet car, nous pouvons l’utiliser pour la fonction FindCarById.

import "reflect"

// models/car_test.go

func TestFindCarById(t *testing.T) {
  car := &Car{Manufacturer: "Volvo", Design: "v40", Style: "urban"}

  NewCar(car)
  carFound := FindCarById(car.Id)

  if car.Id != carFound.Id {
    t.Error("Couldn't find car by id")
  }
}

TestAllCars

Ce test sert surtout à illuster la nécessité de la fonction ResetTableCars, qui permet de supprimer les données existantes de la base de données.

Avec ce reset nous savons que AllCars va nous renvoyer seulement un enregistrement, celui que nous avons créé au sein de la fonction de test, nous allons donc pouvoir comparer correctement le retour de AllCars.

DeepEqual nous permet ici de comparer les champs des structs se trouvant dans cars avec ceux se trouvant dans la slice de retour de la fonction AllCars.

// models/car_test.go

func TestAllCars(t *testing.T) {
  ResetTableCars()

  var cars Cars

  car := Car{Manufacturer: "Volvo", Design: "v40", Style: "urban"}

  NewCar(&car)

  cars = append(cars, *FindCarById(car.Id))

  if !reflect.DeepEqual(&cars, AllCars()) {
    t.Error("Couldn't find correct car from AllCars")
  }
}

Lancement de nos tests

Il y a plusieurs manières de lancer les tests en Go, je vous encourage avant tout à parcourir go test --help pour découvrir les différents flags disponibles lors du lancement des tests.

go test github.com/synbioz/go_api/models va pour sa part lancer les tests seulement pour le package models, pour être en adéquation avec le $GOPATH, il est recommandé de lancer le test en spécifiant le path du projet. go test ./models est équivalent et part du répertoire courant.

go test ./... permet de lancer tous les tests disponibles à partir de notre répertoire courant:

➜  go_api git:(master) ✗ go test ./...
?     github.com/synbioz/go_api [no test files]
?     github.com/synbioz/go_api/config  [no test files]
?     github.com/synbioz/go_api/controllers [no test files]
ok    github.com/synbioz/go_api/models  0.479s

Go nous propose également d’accéder à la couverture de nos tests en rajoutant le flag --cover:

➜  go_api git:(master) ✗ go test ./models --cover
ok    github.com/synbioz/go_api/models  0.540s  coverage: 78.4% of statements

Conclusion

Nous avons pu approcher aujourd’hui la mise en place de tests unitaires sur une application Go, je vous encourage à approfondir ce sujet et à refactoriser le code actuel de nos tests. Vous retrouverez sur le dépôt GitHub du projet le reste des tests pour notre fichier models/car.go.

Dans le cas de tests sur des requêtes http, Go fournit un package qui pourrait vous être utile httptest.

Les sources de cet article sont disponibles sur GitHub.


L’équipe Synbioz. Libres d’être ensemble.

Articles connexes

Ruby, Sidekiq et Crystal

25/07/2019

Dans le cadre d’une application web il nous est tous déjà arrivé de devoir effectuer une tâche assez longue en asynchrone pour ne pas gêner le flux de notre application. En Ruby la solution la plus...

Du dosage à la rouille

13/06/2019

Parlons de la rouille, cette délicieuse sauce qui accompagne nos soupes de poisson. Le bon dosage des ingrédients ravira le palais vos convives ! Pour faire une portion de rouille les ingrédients...

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....