Contactez-nous

Ecriture de tests en Go

Maîtrisez l'écriture de tests en Go : tests unitaires, tests d'intégration, table-driven tests, assertions, mocking, coverage et bonnes pratiques pour un code Go de qualité.

Introduction à l'écriture de tests en Go : Qualité et confiance

L'écriture de tests est une pratique essentielle et incontournable du développement logiciel de qualité. Les tests permettent de valider le bon fonctionnement de votre code, de détecter les bugs et les régressions le plus tôt possible dans le cycle de développement, de garantir la fiabilité et la robustesse de vos applications, et de faciliter la maintenance et l'évolution du code à long terme. Go, avec son package testing intégré à la bibliothèque standard, offre un support natif et excellent pour l'écriture de tests unitaires, de tests d'intégration, et de benchmarks.

Ce chapitre vous propose un guide complet sur l'écriture de tests efficaces en Go. Nous allons explorer en détail les concepts fondamentaux des tests en Go, comment écrire des tests unitaires pour valider les fonctions individuelles, comment créer des tests d'intégration pour vérifier l'interaction entre les composants, comment utiliser les table-driven tests pour simplifier l'écriture de tests répétitifs, comment effectuer des assertions pour vérifier les résultats attendus, comment utiliser le mocking et le stubbing pour isoler les dépendances externes lors des tests, comment mesurer la couverture de code des tests, et les bonnes pratiques pour écrire des tests Go de qualité professionnelle. Que vous soyez novice ou développeur expérimenté, ce guide vous fournira les connaissances et les compétences nécessaires pour maîtriser l'écriture de tests en Go et intégrer les tests de manière efficace dans votre workflow de développement, en visant un code Go robuste, fiable et de haute qualité.

Tests unitaires en Go : Valider les fonctions individuelles

Les tests unitaires sont la forme de test la plus fondamentale et la plus courante. Un test unitaire vise à valider le comportement d'une unité de code isolée, généralement une fonction individuelle (ou une méthode, un package, un module). L'objectif d'un test unitaire est de s'assurer que chaque unité de code fonctionne correctement et conformément à sa spécification, en testant différents cas d'utilisation, entrées valides, entrées invalides, cas limites, et cas d'erreur.

Package testing : Le framework de test intégré de Go

Go propose le package testing, intégré à la bibliothèque standard, qui fournit un framework de test complet et simple à utiliser pour l'écriture et l'exécution de tests unitaires, de tests d'intégration, et de benchmarks. Le package testing définit les conventions, les types de données, et les outils nécessaires pour le testing en Go.

Conventions pour les fichiers de test et les fonctions de test :

  • Fichiers de test : *_test.go : Les fichiers contenant les tests unitaires en Go doivent avoir le suffixe _test.go dans leur nom de fichier (par exemple, mon_fonction_test.go, calculatrice_test.go). Le suffixe _test.go indique au compilateur Go que ces fichiers contiennent du code de test et ne doivent pas être inclus dans le build normal du package.
  • Fonctions de test : TestNomDeLaFonctionATester : Les fonctions de test unitaire doivent commencer par le mot-clé func, avoir un nom commençant par Test (majuscule) suivi du nom de la fonction à tester (avec la première lettre en majuscule), et prendre en argument un seul paramètre de type *testing.T (pointeur vers un type testing.T fourni par le package testing, utilisé pour reporter les erreurs et les échecs de test). Par exemple, pour tester une fonction Additionner, le nom de la fonction de test unitaire pourrait être TestAdditionner.

Structure d'une fonction de test unitaire :

Une fonction de test unitaire typique en Go suit généralement la structure suivante :

func TestNomDeLaFonctionATester(t *testing.T) {
    // 1. Préparation des données de test (inputs, outputs attendus, contexte, etc.)
    // ...

    // 2. Appel de la fonction à tester
    resultatObtenu := FonctionATester(inputs)

    // 3. Vérification du résultat avec des assertions (comparaison du résultat obtenu avec le résultat attendu)
    if resultatObtenu != resultatAttendu {
        t.Errorf("Test NomDeLaFonctionATester échoué : résultat obtenu = %v, résultat attendu = %v", resultatObtenu, resultatAttendu)
    }

    // (Ou, de manière plus idiomatique, en utilisant les fonctions d'assertion du package testing)
    // testing.AssertEqual(t, resultatObtenu, resultatAttendu, "Message d'erreur optionnel")
}

Exemple de tests unitaires pour une fonction Additionner :

package calculatrice

import "testing"

// Fonction à tester : Additionner deux entiers
func Additionner(a, b int) int {
    return a + b
}

// Fonction de test unitaire pour la fonction 'Additionner'
func TestAdditionner(t *testing.T) {
    // Cas de test 1 : Addition de nombres positifs
    resultat1 := Additionner(2, 3)
    attendu1 := 5
    if resultat1 != attendu1 {
        t.Errorf("Test Additionner (cas 1) échoué : obtenu %d, attendu %d", resultat1, attendu1)
    }

    // Cas de test 2 : Addition avec zéro
    resultat2 := Additionner(0, 7)
    attendu2 := 7
    if resultat2 != attendu2 {
        t.Errorf("Test Additionner (cas 2) échoué : obtenu %d, attendu %d", resultat2, attendu2)
    }

    // Cas de test 3 : Addition de nombres négatifs
    resultat3 := Additionner(-5, -2)
    attendu3 := -7
    if resultat3 != attendu3 {
        t.Errorf("Test Additionner (cas 3) échoué : obtenu %d, attendu %d", resultat3, attendu3)
    }
}

Exécution des tests unitaires : go test

Pour exécuter les tests unitaires d'un package Go, utilisez la commande go test dans le répertoire du package (ou dans le répertoire parent pour exécuter les tests de tous les sous-packages).

go test ./calculatrice # Exécuter les tests du package 'calculatrice'
go test ./...        # Exécuter les tests de tous les packages du répertoire courant et des sous-répertoires

La commande go test va compiler les fichiers de test (*_test.go) du package, exécuter toutes les fonctions de test (celles commençant par Test), et afficher un résumé des résultats (PASS si tous les tests réussissent, FAIL en cas d'échec d'au moins un test). En cas d'échec de test, go test affiche également les messages d'erreur (passés à t.Errorf ou t.Fatalf) pour chaque test ayant échoué, permettant d'identifier rapidement les bugs et les problèmes dans votre code.

Assertions dans les tests Go : Vérifier les résultats attendus

Les assertions sont des instructions clés dans les tests unitaires, permettant de vérifier si le résultat obtenu d'une fonction testée correspond au résultat attendu. Le package testing de Go fournit des fonctions pour effectuer des assertions et reporter les échecs de test.

Fonctions d'assertion du package testing :

Le package testing fournit principalement deux fonctions pour les assertions :

  • t.Errorf(format string, args ...interface{}) : Signale un échec de test et affiche un message d'erreur formaté (similaire à fmt.Errorf). t.Errorf ne stoppe pas l'exécution du test en cours, mais continue l'exécution des instructions suivantes du test. Utilisez t.Errorf pour signaler un échec de test non critique, où vous souhaitez continuer à exécuter le reste du test pour détecter d'autres erreurs potentielles.
  • t.Fatalf(format string, args ...interface{}) : Signale un échec de test critique et affiche un message d'erreur formaté (similaire à fmt.Fatalf). t.Fatalf stoppe immédiatement l'exécution du test en cours (en appelant runtime.Goexit après avoir signalé l'erreur). Utilisez t.Fatalf pour signaler un échec de test critique, qui rend la poursuite du test inutile ou invalide.

Types d'assertions courantes :

Dans les tests unitaires, vous effectuerez souvent des assertions pour vérifier différents types de conditions et de résultats :

  • Egalité : Vérifier si le résultat obtenu est égal au résultat attendu (pour les types de données comparables : entiers, strings, booléens, etc.). Utilisez l'opérateur de comparaison == en Go et t.Errorf ou t.Fatalf pour signaler l'échec en cas d'inégalité.
  • Inégalité : Vérifier si le résultat obtenu est différent du résultat attendu. Utilisez l'opérateur de comparaison != et t.Errorf ou t.Fatalf.
  • Valeur nulle (nil) ou non nulle : Vérifier si un pointeur, une interface, un slice, une map ou un channel est nil ou non-nil. Utilisez la comparaison avec nil (if ptr == nil, if err != nil) et t.Errorf ou t.Fatalf.
  • Erreurs : Vérifier si une fonction retourne une erreur (error non-nil) ou pas d'erreur (error nil). Utilisez la comparaison avec nil (if err != nil) et t.Errorf ou t.Fatalf. Pour vérifier le type d'erreur spécifique, utilisez les fonctions errors.Is et errors.As (chapitre 10) et des assertions de type.
  • Contenu de collections (slices, maps) : Vérifier si le contenu d'un slice ou d'une map correspond à un contenu attendu. Comparez la longueur, les éléments, les clés-valeurs, etc. Utilisez des boucles et des comparaisons élément par élément, ou des fonctions de comparaison de slices ou de maps (si disponibles).
  • Assertions personnalisées : Pour des vérifications plus complexes ou spécifiques à votre domaine, vous pouvez créer vos propres fonctions d'assertion personnalisées, qui encapsulent la logique de vérification et le reporting des erreurs. Les fonctions d'assertion personnalisées peuvent améliorer la lisibilité et la réutilisabilité de vos tests.

Exemple d'assertions dans les tests unitaires (avec t.Errorf) :

package calculatrice

import "testing"

func TestAdditionnerAssertions(t *testing.T) {
    // Cas de test 1 : Egalité
    resultat1 := Additionner(2, 3)
    attendu1 := 5
    if resultat1 != attendu1 {
        t.Errorf("Test Egalité échoué : Additionner(2, 3) devrait être %d, mais obtenu %d", attendu1, resultat1)
    }

    // Cas de test 2 : Inégalité
    resultat2 := Additionner(2, 2)
    nonAttendu2 := 5
    if resultat2 == nonAttendu2 {
        t.Errorf("Test Inégalité échoué : Additionner(2, 2) ne devrait pas être %d, mais obtenu %d", nonAttendu2, resultat2)
    }

    // Cas de test 3 : Erreur nil attendue (pas d'erreur)
    err3 := FonctionSansErreur()
    if err3 != nil {
        t.Errorf("Test Erreur nil échoué : FonctionSansErreur ne devrait pas retourner d'erreur, mais obtenu %v", err3)
    }

    // Cas de test 4 : Erreur non-nil attendue (erreur spécifique)
    err4 := FonctionAvecErreur()
    if err4 == nil {
        t.Errorf("Test Erreur non-nil échoué : FonctionAvecErreur devrait retourner une erreur, mais obtenu nil")
    }
    // (Vous pouvez ajouter des assertions supplémentaires pour vérifier le type ou le contenu de l'erreur)
}

Des assertions claires, précises et informatives sont essentielles pour écrire des tests unitaires efficaces, qui permettent de valider le comportement de votre code de manière exhaustive et de détecter rapidement les erreurs et les régressions.

Table-Driven Tests : Simplifier les tests répétitifs avec des tables de données

Lorsque vous devez tester une fonction avec un grand nombre de cas de test, qui se distinguent principalement par leurs entrées et leurs sorties attendues, l'écriture de fonctions de test unitaires individuelles pour chaque cas de test peut devenir répétitive, fastidieuse, et difficile à maintenir. Les table-driven tests (tests pilotés par des tables de données) offrent une solution élégante et concise pour simplifier l'écriture et la gestion de tests répétitifs, en utilisant des tables de données pour définir les cas de test et la logique de test de manière centralisée et itérative.

Principe des Table-Driven Tests :

Les table-driven tests reposent sur les principes suivants :

  • Définir une table de données (slice de structs) : Créez une slice de structs Go qui représente votre table de données de test. Chaque struct dans la slice représente un cas de test individuel, et les champs du struct représentent :
    • Les entrées (inputs) du cas de test (les arguments à passer à la fonction à tester).
    • Le résultat attendu (output attendu) pour ce cas de test.
    • Eventuellement, un nom ou une description pour identifier le cas de test.
  • Itérer sur la table de données : Boucle for...range : Dans votre fonction de test unitaire, utilisez une boucle for...range pour itérer sur chaque élément (cas de test) de votre table de données.
  • Exécuter le test pour chaque cas de test : A chaque itération de la boucle, exécutez le test pour le cas de test courant :
    • Récupérer les inputs du cas de test depuis le struct courant de la table de données.
    • Appeler la fonction à tester avec les inputs récupérés.
    • Récupérer le résultat attendu du cas de test depuis le struct courant de la table de données.
    • Effectuer les assertions (comparaison du résultat obtenu avec le résultat attendu) pour le cas de test courant. Utilisez t.Run (voir section suivante) pour exécuter chaque cas de test comme un sous-test indépendant, améliorant ainsi l'organisation et le reporting des tests.

Avantages des Table-Driven Tests :

  • Réduction de la répétition de code : Les table-driven tests réduisent considérablement la répétition de code dans les tests unitaires, en centralisant la logique de test et en itérant sur une table de données pour exécuter de nombreux cas de test avec un code minimal.
  • Lisibilité et organisation améliorées : Les tests pilotés par des tables de données rendent les tests plus lisibles et plus organisés, en séparant clairement les données de test (table de données) de la logique de test (boucle d'itération et assertions). La table de données permet de visualiser facilement l'ensemble des cas de test et leurs résultats attendus.
  • Facilité d'ajout de nouveaux cas de test : Ajouter de nouveaux cas de test est simple : il suffit d'ajouter de nouvelles entrées à la table de données (de nouveaux structs dans la slice). Les table-driven tests facilitent l'extension de la couverture des tests et l'ajout de nouveaux scénarios de test au fil du temps.
  • Maintenance simplifiée : Les table-driven tests sont plus faciles à maintenir que les tests unitaires répétitifs, car les données de test et la logique de test sont centralisées et structurées. Les modifications ou les corrections de bugs dans la logique de test ou dans les données de test sont plus faciles à effectuer et à propager à tous les cas de test.

Exemple de Table-Driven Test pour la fonction Additionner :

package calculatrice

import "testing"

func TestAdditionnerTableDriven(t *testing.T) {
    // Table de données de test (slice de structs anonymes)
    tests := []struct {
        nomTest   string
        a         int
        b         int
        attendu   int
    }{
        {"Cas 1 : Nombres positifs", 2, 3, 5},
        {"Cas 2 : Avec zéro", 0, 7, 7},
        {"Cas 3 : Nombres négatifs", -5, -2, -7},
        {"Cas 4 : Grand nombres", 1000000, 2000000, 3000000},
    }

    // Boucle sur la table de données pour exécuter les cas de test
    for _, test := range tests {
        t.Run(test.nomTest, func(t *testing.T) {
            resultat := Additionner(test.a, test.b)
            if resultat != test.attendu {
                t.Errorf("Test %s échoué : Additionner(%d, %d) devrait être %d, mais obtenu %d", test.nomTest, test.a, test.b, test.attendu, resultat)
            }
        })
    }
}

Dans cet exemple, la fonction de test TestAdditionnerTableDriven utilise un table-driven test pour tester la fonction Additionner avec 4 cas de test différents, définis dans la slice de structs tests. La boucle for...range itère sur chaque cas de test, et t.Run(test.nomTest, func(t *testing.T) { ... }) exécute chaque cas de test comme un sous-test indépendant, améliorant l'organisation et le reporting des résultats de test.

Sous-tests (Subtests) : Organiser et structurer les tests avec t.Run

La méthode t.Run(name string, f func(t *testing.T)) bool du package testing permet de définir des sous-tests (subtests) au sein d'une fonction de test unitaire. Les sous-tests permettent d'organiser et de structurer vos tests unitaires de manière hiérarchique, de regrouper les tests par cas d'utilisation, par scénarios, ou par catégories, et d'améliorer le reporting des résultats de test.

Avantages des sous-tests (t.Run) :

  • Organisation et structuration des tests : Les sous-tests permettent de structurer vos tests unitaires de manière plus logique et plus organisée, en regroupant les tests par cas de test, par scénario, par fonctionnalité, ou par catégorie. La structure hiérarchique des sous-tests facilite la navigation, la compréhension et la maintenance des tests.
  • Reporting des résultats de test amélioré : t.Run exécute chaque sous-test comme un test indépendant, avec son propre nom et son propre résultat (PASS ou FAIL). La commande go test affiche les résultats de chaque sous-test individuellement, permettant d'identifier plus facilement les tests ayant échoué et de comprendre la nature de l'échec pour chaque cas de test. Les noms des sous-tests (passés à t.Run) sont utilisés dans le reporting des tests, rendant les résultats plus clairs et plus informatifs.
  • Exécution sélective des sous-tests : La commande go test permet de sélectionner et d'exécuter uniquement certains sous-tests spécifiques, en utilisant des patterns de nommage dans l'option -run. Cela est utile pour se concentrer sur les tests d'une fonctionnalité particulière ou pour déboguer un sous-ensemble de tests en cas d'échec.
  • Réutilisation de code (setup/teardown par sous-test) : Chaque fonction de sous-test passée à t.Run (func(t *testing.T) { ... }) a son propre contexte d'exécution et peut avoir son propre code de setup (préparation) et de teardown (nettoyage) spécifique au sous-test. Cela permet de réutiliser le code de setup et de teardown pour un groupe de tests similaires au sein d'une même fonction de test unitaire.

Exemple d'utilisation de sous-tests (t.Run) dans un Table-Driven Test :

Reprenons l'exemple de Table-Driven Test pour la fonction Additionner (section précédente) et utilisons t.Run pour organiser les cas de test en sous-tests nommés :

package calculatrice

import "testing"

func TestAdditionnerTableDrivenSubtests(t *testing.T) {
    // Table de données de test (slice de structs anonymes)
    tests := []struct {
        nomTest   string
        a         int
        b         int
        attendu   int
    }{
        {"Cas 1 : Nombres positifs", 2, 3, 5},
        {"Cas 2 : Avec zéro", 0, 7, 7},
        {"Cas 3 : Nombres négatifs", -5, -2, -7},
        {"Cas 4 : Grand nombres", 1000000, 2000000, 3000000},
    }

    // Boucle sur la table de données pour exécuter les cas de test comme des sous-tests
    for _, test := range tests {
        test := test // Capture de la variable de portée de boucle pour éviter le "loop variable capture" (piège courant en Go)
        t.Run(test.nomTest, func(t *testing.T) {
            // Code de test pour chaque sous-test (exécuté par t.Run)
            resultat := Additionner(test.a, test.b)
            if resultat != test.attendu {
                t.Errorf("Additionner(%d, %d) devrait être %d, mais obtenu %d", test.a, test.b, test.attendu, resultat)
            }
        })
    }
}

Dans cet exemple, la boucle for...range itère sur la table de données tests, et t.Run(test.nomTest, func(t *testing.T) { ... }) exécute le code de test pour chaque cas de test comme un sous-test indépendant, nommé d'après la valeur du champ test.nomTest. Lors de l'exécution des tests avec go test -v, vous verrez les résultats de chaque sous-test individuellement, avec leur nom de test (par exemple, --- PASS: TestAdditionnerTableDrivenSubtests/Cas_1_:_Nombres_positifs, --- FAIL: TestAdditionnerTableDrivenSubtests/Cas_3_:_Nombres_négatifs), facilitant l'identification des cas de test ayant réussi ou échoué.

Mocking et Stubbing : Isoler les dépendances externes pour les tests unitaires

Lors de l'écriture de tests unitaires, il est essentiel d'isoler l'unité de code testée (généralement une fonction) de ses dépendances externes (autres fonctions, packages, services externes, bases de données, APIs, etc.). L'isolation des dépendances permet de tester l'unité de code de manière indépendante, contrôlée et prévisible, sans être affecté par le comportement ou les erreurs potentielles des dépendances externes.

Le mocking et le stubbing sont des techniques couramment utilisées dans les tests unitaires pour remplacer les dépendances externes réelles par des implémentations de remplacement contrôlées (mocks ou stubs) lors des tests. Les mocks et les stubs permettent de simuler le comportement des dépendances externes de manière isolée et de vérifier les interactions entre l'unité de code testée et ses dépendances.

Différence entre Mocks et Stubs :

  • Stubs (Bouchons) : Les stubs sont des implémentations de remplacement simples des dépendances externes, qui sont utilisées uniquement pour fournir des réponses prédéfinies (outputs) aux appels de fonctions ou de méthodes de la dépendance externe. Les stubs sont principalement utilisés pour isoler l'unité de code testée et pour contrôler les entrées (inputs) de l'unité de code. Les stubs ne sont généralement pas utilisés pour vérifier les interactions ou les appels vers la dépendance externe.
  • Mocks (Mock Objects - Objets Simulés) : Les mocks sont des objets simulés plus sophistiqués qui enregistrent les appels de méthodes ou de fonctions qui leur sont faits, et qui permettent de vérifier les interactions et les appels entre l'unité de code testée et la dépendance externe. Les mocks permettent de spécifier des comportements attendus (expectations) pour les appels de méthodes (par exemple, "la méthode MethodeX doit être appelée une fois avec l'argument "valeur""), et de vérifier que ces attentes sont bien respectées lors de l'exécution du test. Les mocks sont utilisés pour tester l'interaction et la collaboration entre l'unité de code testée et ses dépendances, au-delà de la simple validation des sorties.

Techniques de Mocking et Stubbing en Go :

  • Définir des interfaces pour les dépendances externes : Pour faciliter le mocking et le stubbing, il est recommandé de définir des interfaces Go pour abstraire les dépendances externes de votre code. Les interfaces permettent de définir un contrat (un ensemble de méthodes) que les dépendances externes doivent implémenter, et de remplacer facilement les implémentations réelles par des mocks ou des stubs qui implémentent les mêmes interfaces lors des tests. L'injection de dépendances (dependency injection - DI) via les interfaces facilite le remplacement des dépendances externes par des mocks ou des stubs lors des tests.
  • Utiliser des mocks "manuels" (structs et fonctions de remplacement) : Pour les cas simples, vous pouvez implémenter des mocks "manuels" en créant des structs Go qui implémentent les interfaces des dépendances externes, et en fournissant des implémentations de remplacement (stubs ou mocks) pour les méthodes de ces interfaces. Les mocks manuels sont simples à créer et à utiliser, mais ils peuvent devenir fastidieux à maintenir et à étendre pour les dépendances complexes ou pour un grand nombre de mocks.
  • Utiliser des frameworks de mocking Go (GoMock, testify/mock, Moq, etc.) : Pour les cas plus complexes ou pour simplifier la création et la gestion des mocks, vous pouvez utiliser des frameworks de mocking Go, comme GoMock (officiel, go.uber.org/mock/gomock), testify/mock (de la suite testify, github.com/stretchr/testify/mock), Moq (github.com/matryer/moq), etc. Ces frameworks de mocking offrent des outils pour générer automatiquement du code de mock à partir d'interfaces Go, pour définir des comportements attendus (expectations) pour les mocks, et pour vérifier les interactions avec les mocks lors des tests. GoMock est le framework de mocking le plus ancien et le plus largement utilisé en Go, et est souvent considéré comme le framework de facto pour le mocking en Go.

Exemple de Stub manuel pour isoler une dépendance externe (service d'email) :

package utilisateur

import "errors"

// Interface 'ServiceEmail' pour abstraire la dépendance au service d'email externe
type ServiceEmail interface {
    EnvoyerEmail(destinataire string, sujet string, corps string) error
}

// Implémentation réelle du service d'email (utilisation d'une API email réelle)
type ServiceEmailReel struct{ /* ... */ }

func (s *ServiceEmailReel) EnvoyerEmail(destinataire string, sujet string, corps string) error {
    // ... (code pour envoyer l'email via une API email réelle) ...
    return nil // ou erreur en cas d'échec
}

// Stub du service d'email pour les tests unitaires (implémentation de remplacement)
type StubServiceEmail struct {
    erreurRetournee error // Erreur à retourner par le stub (pour simuler des cas d'erreur)
}

func (s StubServiceEmail) EnvoyerEmail(destinataire string, sujet string, corps string) error {
    // Stub : ne pas envoyer d'email réel, retourner simplement l'erreur configurée (ou nil si pas d'erreur configurée)
    return s.erreurRetournee
}

// Fonction à tester : Inscrire un utilisateur (dépend du service d'email)
func InscrireUtilisateur(nom string, email string, serviceEmail ServiceEmail) error {
    // ... (validation de l'utilisateur, logique métier, etc.) ...
    err := serviceEmail.EnvoyerEmail(email, "Bienvenue", "Bienvenue sur notre plateforme !") // Appel au service d'email (dépendance externe)
    if err != nil {
        return fmt.Errorf("Erreur lors de l'envoi de l'email de bienvenue: %w", err)
    }
    // ... (enregistrement de l'utilisateur dans la base de données, etc.) ...
    return nil
}

Exemple de test unitaire utilisant le Stub manuel (StubServiceEmail) pour tester InscrireUtilisateur :

package utilisateur_test

import (
    "errors"
    "testing"
    "utilisateur"
)

func TestInscrireUtilisateurStub(t *testing.T) {
    // Cas de test 1 : Inscription réussie, StubServiceEmail sans erreur
    stubEmailSucces := utilisateur.StubServiceEmail{erreurRetournee: nil} // Stub qui simule un envoi d'email réussi (pas d'erreur)
    err1 := utilisateur.InscrireUtilisateur("Alice", "alice@example.com", stubEmailSucces)
    if err1 != nil {
        t.Errorf("Test Cas Succès échoué : Inscription devrait réussir, mais erreur retournée: %v", err1)
    }

    // Cas de test 2 : Inscription échouée (erreur d'envoi d'email simulée avec le stub)
    erreurEmailSimulee := errors.New("Erreur simulée d'envoi d'email")
    stubEmailErreur := utilisateur.StubServiceEmail{erreurRetournee: erreurEmailSimulee} // Stub qui simule une erreur d'envoi d'email
    err2 := utilisateur.InscrireUtilisateur("Bob", "bob@example.com", stubEmailErreur)
    if err2 == nil {
        t.Errorf("Test Cas Erreur échoué : Inscription devrait échouer (erreur email), mais pas d'erreur retournée")
    }
    if !errors.Is(err2, erreurEmailSimulee) {
        t.Errorf("Test Cas Erreur échoué : Erreur retournée devrait être de type erreurEmailSimulee, mais obtenu %v", err2)
    }
}

Dans cet exemple, nous utilisons un Stub manuel StubServiceEmail pour remplacer l'implémentation réelle du service d'email (ServiceEmailReel) lors des tests unitaires de la fonction InscrireUtilisateur. Le stub StubServiceEmail permet de contrôler le comportement du service d'email (simuler un envoi réussi ou un échec) et de tester la logique de gestion des erreurs de InscrireUtilisateur de manière isolée et prévisible.

Mesurer la couverture de code des tests : Go test -cover

La couverture de code (code coverage) est une métrique importante pour évaluer la qualité et l'exhaustivité de vos tests unitaires. La couverture de code mesure le pourcentage de code source de votre application qui est exécuté (couvert) par vos tests unitaires. Une couverture de code élevée ne garantit pas l'absence de bugs, mais elle indique que vos tests explorent une part importante du code et valident un large éventail de scénarios et de chemins d'exécution.

Outil de couverture de code de Go : go test -cover

Go propose un outil intégré pour mesurer la couverture de code de vos tests unitaires : l'option -cover de la commande go test.

Exécution des tests avec couverture de code : go test -cover

Pour exécuter les tests unitaires avec la couverture de code activée, utilisez la commande go test -cover ./... (ou go test -cover ./package_a_tester pour un package spécifique) dans le répertoire racine de votre projet Go.

go test -cover ./...

La commande go test -cover va :

  • Exécuter tous les tests unitaires du package (et des sous-packages si ./... est utilisé).
  • Instrumenter le code binaire compilé pour la couverture de code, en ajoutant des instructions de comptage pour chaque bloc de code exécutable.
  • Exécuter le code instrumenté et collecter les données de couverture pendant l'exécution des tests.
  • Afficher un résumé de la couverture de code pour chaque package testé, sous forme de pourcentage (par exemple, coverage: 82.5% of statements).

Rapport de couverture de code détaillé : go test -coverprofile=couverture.out et go tool cover

Pour obtenir un rapport de couverture de code plus détaillé (ligne par ligne, fonction par fonction), et pour visualiser la couverture de code dans un navigateur web, vous pouvez utiliser les options -coverprofile=couverture.out et go tool cover -html=couverture.out.

Etapes pour générer et visualiser un rapport de couverture de code HTML :

  1. Exécuter les tests avec -coverprofile : Exécutez les tests unitaires avec l'option -coverprofile=couverture.out pour enregistrer les données de couverture dans un fichier (par exemple, couverture.out).
    go test -coverprofile=couverture.out ./...
    
  2. Visualiser le rapport HTML avec go tool cover -html : Utilisez la commande go tool cover -html=couverture.out pour générer un rapport de couverture de code HTML à partir du fichier de données de couverture (couverture.out) et l'ouvrir automatiquement dans votre navigateur web par défaut.
    go tool cover -html=couverture.out
    

Le rapport de couverture de code HTML affiche le code source de vos packages, en colorant les lignes de code en fonction de leur couverture par les tests :

  • Vert : Lignes de code exécutées (couvertes) par au moins un test unitaire.
  • Rouge : Lignes de code non exécutées (non couvertes) par aucun test unitaire.
  • Jaune : Lignes de code partiellement couvertes (cas rares).

Le rapport de couverture de code HTML permet d'identifier visuellement les zones de code non couvertes par les tests et de cibler vos efforts d'écriture de tests sur ces zones, en visant à augmenter la couverture de code globale de votre application.

Interprétation et limites de la couverture de code :

  • Couverture de code élevée : un indicateur positif, mais pas une garantie absolue : Une couverture de code élevée (par exemple, 80-90% ou plus) est généralement un indicateur positif de la qualité et de l'exhaustivité de vos tests unitaires. Elle suggère que vos tests explorent une part importante du code et valident un large éventail de scénarios. Cependant, une couverture de code élevée ne garantit pas à elle seule l'absence de bugs. Un code avec une couverture de code élevée peut toujours contenir des bugs si les tests ne sont pas bien conçus, s'ils ne testent pas les cas limites ou les cas d'erreur importants, ou s'ils ne vérifient pas les aspects fonctionnels critiques de l'application.
  • Couverture de code faible : un signal d'alerte : Une couverture de code faible (par exemple, inférieure à 50-60%) est généralement un signal d'alerte indiquant que vos tests unitaires sont potentiellement insuffisants et qu'une part importante du code n'est pas validée par les tests. Une couverture de code faible augmente le risque de bugs non détectés et de régressions lors des modifications du code. Dans ce cas, il est important d'augmenter la couverture de code en écrivant davantage de tests unitaires, en ciblant les zones de code non couvertes, et en améliorant la qualité et l'exhaustivité de vos tests.
  • Vise une couverture de code raisonnablement élevée, mais pas la couverture de code à 100% à tout prix : Visez une couverture de code raisonnablement élevée (par exemple, 80-90% ou plus) comme objectif de qualité pour vos tests unitaires. Cependant, ne cherchez pas à atteindre la couverture de code à 100% à tout prix, car cela peut être difficile, coûteux, et parfois contre-productif. Certaines parties du code (par exemple, le code de gestion des erreurs très rares, le code de configuration, le code de logging) peuvent être moins pertinentes à tester unitairement, et viser une couverture de code à 100% peut conduire à écrire des tests artificiels ou inutiles, sans apporter de réelle valeur ajoutée en termes de qualité et de fiabilité du code. Concentrez-vous plutôt sur la couverture des parties critiques et complexes de votre code (logique métier, algorithmes, gestion des erreurs, interactions avec les dépendances externes, etc.), et visez une couverture de code qui vous donne une confiance raisonnable dans la qualité et la robustesse de votre application.

La couverture de code est un indicateur utile pour guider vos efforts d'écriture de tests unitaires et pour mesurer l'exhaustivité de vos tests, mais elle ne doit pas être considérée comme une métrique absolue ou un objectif en soi. La qualité et la pertinence des tests (ce que vous testez, comment vous le testez, la qualité des assertions, la couverture des cas d'utilisation et des cas d'erreur importants) sont au moins aussi importantes que la couverture de code brute.