Principaux modules Go : Écriture de tests unitaires avec Testify

MISE À JOUR : À compter du 1er mai 2021, le dépôt central GoCenter a été mis hors service et toutes ses fonctionnalités seront obsolètes. Pour en savoir plus sur la mise hors service des centres, lisez l'article de blog sur la dépréciation

 

 

Chaque mois, GoCenter décerne aux modules les plus performants un badge Gopher qui reconnaît leur réussite. Cet article mentionne quelques-uns de ces meilleurs modules et leur utilisation dans Go.

Tous les développeurs en ont vu, même dans des programmes Golang bien structurés : des commentaires qui vous suggèrent de ne pas toucher aux lignes de code, vu qu'elles semblent fonctionner par magie. Ces avertissements nous rendent timides, car nous craignons de casser quelque chose. Mais les applications doivent changer, s’améliorer et innover.

C’est pour cela que les tests unitaires sont une partie essentielle du développement de logiciels. Ils aident les développeurs à déterminer si les petites parties de leur logiciel remplissent correctement la fonction prévue. Avec la quantité adaptée de couverture de tests unitaires en place, les développeurs se sentent plus en confiance pour modifier leurs implémentations, et même la réusiner à partir de zéro. Ils savent en effet qu’ils peuvent facilement vérifier si la nouvelle version fonctionne toujours comme prévu.

L’importance des tests unitaires et d’un ensemble solide d’outils dans le même langage augmente parallèlement à la complexité des logiciels. Un code de test offrant une bonne couverture pouvant être volumineux, il doit être aussi lisible et facile à maintenir que le code du produit, afin d'encourager les développeurs à l’utiliser et à en tirer parti.

Pour nos projets de communauté Go tels que GoCenter, nous utilisons beaucoup le module Testify, très apprécié. Il fournit un ensemble de packages Golang pour réaliser des fonctions de test unitaire essentielles. 

Cet article vous montre comment vous pouvez utiliser les principales fonctionnalités de Testify pour écrire des tests unitaires dans Go, faciles à lire et à maintenir. Vous verrez à quoi ressembleraient les tests unitaires avec l’utilisation de Go uniquement, découvrirez les packages de Testify qui peuvent vous aider concernant la tâche effectuée, puis le code résultant après l’adoption de Testify. Nous allons vous montrer quelques meilleures pratiques pour effectuer des assertions et écrire des mocks pour les dépendances.

Testify : Un « Top Gopher »

Testify est un ensemble de packages convivial pour les développeurs. Il possède plus de 11 000 étoiles sur GitHub et bénéficie d’un excellent support communautaire. Testify étend le cadre de tests léger de Go pour effectuer des assertions et des dépendances fictives.

Ces fonctionnalités, en plus de la confiance quotidienne de notre équipe communautaire Go, expliquent en grande partie pourquoi le module Testify est reconnu en tant que « Top Gopher » dans GoCenter. Si vous consultez les métadonnées complètes de GoCenter sur le module Testify, vous comprendrez pourquoi :

  • Le fichier ReadMe du module vous dirige vers une documentation complète. Nous pouvons en savoir plus sur le code du module via l’onglet GoDoc, qui affiche la documentation générée automatiquement sur les fonctions et plus encore.
  • Les onglets Used By et Metrics de GoCenter montrent que ce module est très apprécié et fiable, avec de nombreux téléchargements, forks, contributeurs et utilisations par d’autres modules Go.
  • L'onglet Security de GoCenter révèle également que la version actuelle de ce module et ses dépendances ne présentent pas de vulnérabilité NVD connue, comme le confirme une analyse approfondie JFrog Xray.

Une Unité GoLang Simple

Pour commencer à écrire les tests unitaires, nous avons tout d'abord besoin d’un composant à tester. Pour cet exercice, nous allons utiliser la définition de service suivante :

type ProductService interface {
	IsProductReservable(id int) (bool, error)
}

 

Pour cette définition de service, nous avons une implémentation que nous souhaitons tester. L’implémentation obéit à une logique métier pour déterminer si un produit est réservable ou non. L’implémentation s'appuie également sur un composant Objet d’accès aux données pour fournir des informations sur les produits. L’implémentation doit réussir les cas de test simplifiés suivants :

  • L’implémentation de service doit respecter la définition de service
  • Les produits ajoutés au catalogue il y a plus de 1 an sont réservables
  • Les autres produits ne sont pas réservables
  • Les produits ne figurant pas dans le catalogue doivent provoquer une erreur de produit introuvable

L’implémentation du service ressemble à ce qui suit :

type ProductServiceImpl struct {
	productDAO persist.ProductDAO
}

// Constructor
func NewProductServiceImpl(dao persist.ProductDAO) *ProductServiceImpl {
	return &ProductServiceImpl{
		productDAO: dao,
	}
}

func (s *ProductServiceImpl) IsProductReservable(id int) (bool, error) {
	// Get product information from database
	product, err := s.productDAO.GetProduct(id)
	if err != nil {
		return false, fmt.Errorf("failed to get product details: %w", err)
	}

	if product == nil {
		return false, fmt.Errorf("product not found for id %v", id)
	}

	// Only products added more than 1 year ago to the catalog can be reserved
	return product.CreatedAt.Before(time.Now().AddDate(-1, 0, 0)), nil
}

Utilisation de Testify

Maintenant que nous disposons d’un service simple, nous pouvons utiliser Testify pour créer des tests unitaires qui garantissent qu’il fonctionne comme prévu.

Exécution d’Assertions

Les tâches les plus élémentaires effectuées par les tests unitaires sont des assertions. Les assertions permettent généralement de vérifier si les actions effectuées par le test à l’aide d’une entrée déterminée produisent la sortie attendue. Elles peuvent également être utilisées pour vérifier si les composants suivent les règles de conception souhaitées. 

En utilisant Go uniquement pour exécuter les assertions nécessaires pour vérifier si le premier cas de test est respecté et si notre implémentation de service est initialisée correctement, nous obtenons le code suivant :

import (	
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	productDaoMock := ProductDaoMock{} // Ignorer le mock pour l'instant
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	// Asserts ProductServiceImpl implements ProductService. Will break the compiler if it does not.
	var _ service.ProductService = productServiceImpl

	if productServiceImpl == nil {
		t.Fatal("Product Service not initialized")
	}

	if productServiceImpl.productDAO == nil {
		t.Fatal("Product Service dependency not initialized")
	}
}

Pour aider avec les assertions, Testify fournit un package  github.com/stretchr/testify/assert. Ce package possède plusieurs méthodes qui peuvent aider à comparer les valeurs par rapport aux résultats attendus. Si nous remplaçons nos comparaisons par ces méthodes, nous obtenons le résultat suivant :

import (
	"github.com/stretchr/testify/assert"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := assert.New(t)
	productDaoMock := ProductDaoMock{} // Ignorer le mock pour l'instant
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	if !assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl)) {
		t.Fatal("Product Service Implementation does not honor service definition")
	}

	if !assertions.NotNil(productServiceImpl, "Product Service not initialized") {
		t.Fatal("Product Service not initialized")
	}

	if !assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized") {
		t.Fatal("Product Service dependency not initialized")
	}
}

En plus d’aider avec les assertions, les packages Testify fournissent également une meilleure information lorsque l’une de ces opérations échoue. Par exemple, si nous avons oublié de définir le champ productDAO dans le constructeur d’implémentation de service, nous obtenons l’échec de test suivant :

=== RUN   TestNewProductServiceImpl
    TestNewProductServiceImpl: product_service_impl_test.go:22:
        	Error Trace:	product_service_impl_test.go:22
        	Error:      	Expected value not to be nil.
        	Test:       	TestNewProductServiceImpl
        	Messages:   	Product Service dependency not initialized
    TestNewProductServiceImpl: product_service_impl_test.go:23: Product Service dependency not initialized
--- FAIL: TestNewProductServiceImpl (0.00s)

Jusqu’à présent, même si nous disposons d’une meilleure information et de méthodes plus pratiques pour exécuter les assertions, nous ne pouvions pas réduire la taille de notre test. Nous avons toujours un modèle répétitif if-not-assertion-break, qui peut rendre plus difficile la lecture de notre code de test. Testify fournit un package pour faciliter ce point github.com/stretchr/testify/require. Ce package possède les mêmes méthodes d’assertion fournies par le package d’assertion, mais il interrompt le test immédiatement lorsqu’une assertion échoue. En introduisant ce package, nous obtenons le code de test suivant, plus court et plus facile à lire :

import (
	"github.com/stretchr/testify/require"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := require.New(t)

	productDaoMock := ProductDaoMock{} // Ignorer le mock pour l'instant
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl), "Product Service Implementation does not honor service definition")
	assertions.NotNil(productServiceImpl, "Product Service not initialized")
	assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized")
}

 

Mocking de Dépendances

Lors du test d’un composant, nous devons idéalement l’isoler complètement pour éviter d’avoir des échecs ailleurs qui compromettraient nos tests. Cela est particulièrement difficile lorsque le composant que nous voulons tester a des dépendances sur d’autres composants depuis différentes couches de notre logiciel. Dans le scénario que nous utilisons ici, notre implémentation de service repose sur un composant de la couche DAO (Data Access Object, Objet d'accès aux données) pour accéder aux informations sur les produits.

Pour promouvoir l’isolation souhaitée, il est courant que les développeurs écrivent de fausses implémentations simplifiées de ces dépendances, à utiliser pendant les tests. Ces fausses implémentations sont nommées mocks.

Nous pouvons créer une implémentation fictives du ProductDAO à injecter dans l’implémentation du service pour l’exécution du test. L’interface ProductDAO que notre mock doit implémenter ressemble à ce qui suit :

type ProductDAO interface {
	GetProduct(id int) (*model.Product, error)
}

Pour permettre l’exécution du test, il est nécessaire que le mock fournisse un comportement compatible avec tous les cas de test que nous voulons valider. Sinon, nous ne pouvons pas atteindre la couverture de test souhaitée. En utilisant Go uniquement, notre cas de test avec le mock ressemblerait à ce qui suit :

import (
	"errors"
	"model"
	"persist"
	"testing"
	"time"
)

type ProductDaoMock struct {
}

func (m *ProductDaoMock) GetProduct(id int) (*model.Product, error) {
	switch id {
	case 1:
		return &model.Product{
			Id:          1,
			Description: "Product created 2 years ago",
			CreatedAt:   time.Now().AddDate(-2, 0, 0),
		}, nil
	case 2:
		return &model.Product{
			Id:          2,
			Description: "Product recently created",
			CreatedAt:   time.Now(),
		}, nil
	case 999:
		return nil, persist.ErrProductNotFound
	}
	return nil, nil
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		if err != nil {
			t.Fatalf("Failed to check if product %v is reservable: %s", productId, err)
		}

		if reservable != expectedResult {
			t.Fatalf("Got wrong reservable info for product id %v. Expected: %v. Got: %v", productId, expectedResult, reservable)
		}
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(999)
	if !errors.Is(err, persist.ErrProductNotFound) {
		t.Fatalf("Got unexpected error result: %s", err)
	}
}

 

Le principal problème avec l’approche ci-dessus est que notre logique de cas de test est désormais distribuée. Une partie de celle-ci est implémentée dans le cas de test lui-même, où nous envoyons des événements au composant testé et exécutons des assertions avec les résultats. L’autre partie est implémentée dans le mock, qui doit fournir un comportement compatible avec les tests réalisés par le cas de test. Il est facile de voir comment notre cas de test pourrait s’interrompre, non pas en raison d’un problème dans le test lui-même, mais parce que le mock ne retourne pas les données requises. 

Un autre problème qui peut le rendre encore plus frustrant est que nous partageons également le mock entre plusieurs cas de test. Il est possible que les modifications appliquées au mock pour qu'il satisfasse un cas de test en interrompent d’autres. Dans notre scénario, seuls 3 cas de test qui nous intéressent, mais vous pouvez imaginer à quel point cela pourrait devenir compliqué si nous avions des cas de test plus complexes. Diviser le mock en plusieurs mocks ne serait pas non plus forcément utile, et pourrait potentiellement aggraver la situation en répandant encore davantage la complexité. De plus, si notre interface fictive change, nous devons mettre à jour plusieurs mocks pour qu'ils restent compatibles.

Nous devons garder la logique du cas de test centralisée et indépendante. Testify fournit un package pour faciliter ce point github.com/stretchr/testify/mock. Ce package fournit des outils pour créer des mocks qui permettent d’injecter le comportement dans l’exécution, ce qui permet au cas de test de le faire. La logique fictive reste ainsi proche de la logique du test.

En utilisant le package fictif Testify pour créer notre mock DAO et en déplaçant l’initialisation du comportement fictif vers les cas de test, ainsi qu’en ajoutant le package Testify requis pour exécuter nos assertions, notre code de test ressemble à ce qui suit :

import (
      "github.com/stretchr/testify/require"
      "github.com/stretchr/testify/mock"
	"errors"
	"model"
	"persist"	
	"testing"
	"time"
)

type ProductDaoTestifyMock struct {
	mock.Mock
}

func (m *ProductDaoTestifyMock) GetProduct(id int) (*model.Product, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Product), args.Error(1)
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return(&model.Product{
		Id:          1,
		Description: "Product created 2 years ago",
		CreatedAt:   time.Now().AddDate(-2, 0, 0),		
	}, nil)
	productDaoMock.On("GetProduct", 2).Return(&model.Product{
		Id:          2,
		Description: "Product recently created",
		CreatedAt:   time.Now(),
	}, nil)

	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		assertions.NoErrorf(err,"Failed to check if product %v is reservable: %s", productId, err)
		assertions.Equalf(expectedResult, reservable,"Got wrong reservable info for product id %v", productId)
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return((*model.Product)(nil), persist.ErrProductNotFound)

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(1)

	if !errors.Is(err, persist.ErrProductNotFound) {
		assertions.Failf("Got unexpected error result", "Got unexpected error result: %s", err)
	}
}

Dans l’implémentation ci-dessus, notez comment le comportement fictif et la logique de test sont tous deux centralisés dans le cas de test. Notez également comment le comportement fictif enregistré est exclusif au cas de test où il est placé, car il appartient à une instance fictive unique qui n’est pas partagée entre plusieurs tests. Les tests enregistrent même des comportements différents pour l’ID de produit 1 sans aucun problème. ProductDaoTestifyMock peut être réutilisé en toute sécurité entre plusieurs cas de test, car il n’a aucun comportement.

Conclusion

J’espère que vous avez trouvé des informations utiles dans cet article et qu’il pourra vous aider à créer de meilleurs tests unitaires dans vos projets. Pour ajouter Testify à votre projet à l’aide des modules Go et commencer à vous amuser avec, exécutez simplement les commandes suivantes :

$ export GOPROXY=https://gocenter.io
$ go get github.com/stretchr/testify

Consultez Testify sur GoCenter ou faites des recherches pour découvrir encore plus de modules Go très intéressants.