Contactez-nous

Mocking et stubbing

Maîtrisez mocking et stubbing en Go : techniques d'isolation des dépendances, stubs manuels, GoMock, choix entre mocks et stubs, et bonnes pratiques pour des tests unitaires robustes et ciblés.

Introduction au mocking et au stubbing : Isoler pour mieux tester

Dans le domaine des tests unitaires, l'isolation est un principe fondamental. Un test unitaire doit se concentrer sur la validation du comportement d'une unité de code isolée (généralement une fonction ou une méthode), sans être influencé par le comportement ou les erreurs potentielles de ses dépendances externes (autres fonctions, packages, services, bases de données, APIs, etc.). C'est là que les techniques de mocking et de stubbing entrent en jeu.

Imaginez que vous testiez une fonction qui dépend d'un service d'email externe pour envoyer un email de confirmation lors de l'inscription d'un utilisateur. Sans isolation, votre test unitaire de la fonction d'inscription deviendrait un test d'intégration, dépendant du service d'email réel (qui peut être lent, peu fiable, ou coûteux à configurer pour les tests). Le mocking et le stubbing vous permettent de remplacer le service d'email réel par une implémentation de remplacement contrôlée (un mock ou un stub), simulant son comportement de manière isolée et prévisible, et vous permettant de tester uniquement la logique de votre fonction d'inscription, sans dépendance externe.

Ce chapitre explore en profondeur les techniques de mocking et de stubbing en Go. Nous allons détailler ce que sont les stubs et les mocks, leurs différences et leurs cas d'utilisation respectifs, comment les implémenter en Go (manuellement ou avec des frameworks de mocking comme GoMock), et les bonnes pratiques pour utiliser efficacement le mocking et le stubbing afin d'écrire des tests unitaires isolés, rapides, fiables, et ciblés sur l'unité de code que vous souhaitez tester. Que vous soyez novice ou expérimenté en testing, ce guide complet vous fournira les clés pour maîtriser ces techniques essentielles et améliorer significativement la qualité et la testabilité de votre code Go.

Stubs (Bouchons) : Simuler les dépendances pour contrôler les entrées

Un stub (bouchon) est une implémentation de remplacement simplifiée d'une dépendance externe, utilisée dans les tests unitaires pour isoler le code testé et contrôler les entrées (inputs) de l'unité de code testée. Un stub se contente généralement de simuler le comportement de base de la dépendance externe, en retournant des valeurs prédéfinies (outputs) pour les appels de fonctions ou de méthodes de la dépendance, sans implémenter toute la logique complexe de la dépendance réelle.

Rôle et avantages des Stubs :

  • Isolation de dépendances : Les stubs permettent d'isoler l'unité de code testée de ses dépendances externes, en remplaçant les dépendances réelles par des implémentations de remplacement contrôlées. Cela garantit que le test unitaire se concentre uniquement sur la logique de l'unité de code testée, sans être influencé par le comportement ou les erreurs des dépendances externes.
  • Contrôle des inputs : Les stubs permettent de contrôler précisément les inputs (les valeurs de retour) des dépendances externes, en configurant le stub pour retourner des valeurs prédéfinies spécifiques à chaque cas de test. Cela permet de tester différents scénarios et cas limites de l'unité de code testée, en contrôlant les réponses des dépendances externes.
  • Simplification des tests : Les stubs simplifient l'écriture et l'exécution des tests unitaires, en évitant la nécessité de configurer et de gérer des dépendances externes complexes ou lourdes (bases de données réelles, services externes, etc.) lors des tests unitaires. Les stubs rendent les tests plus rapides, plus fiables, et plus faciles à mettre en place.
  • Testabilité du code legacy ou complexe : Les stubs peuvent être particulièrement utiles pour tester du code legacy ou du code complexe qui dépend de nombreuses dépendances externes difficiles à maîtriser ou à tester directement. Les stubs permettent d'isoler et de tester progressivement les différentes parties du code legacy, en remplaçant les dépendances externes par des stubs au fur et à mesure.

Implémentation manuelle de Stubs en Go (avec interfaces et structs) :

En Go, les stubs sont généralement implémentés manuellement en utilisant des interfaces et des structs. Pour chaque dépendance externe que vous souhaitez stubber, vous définissez une interface qui décrit le comportement de la dépendance (les méthodes que l'unité de code testée appelle sur la dépendance). Vous créez ensuite un struct Stub qui implémente cette interface, en fournissant des implémentations de remplacement (stubs) pour les méthodes de l'interface, qui retournent des valeurs prédéfinies ou simulent un comportement simple.

Exemple d'implémentation manuelle de Stub (StubServiceEmail - voir exemple du chapitre précédent) :

package utilisateur

// Interface 'ServiceEmail' (définie dans le chapitre précédent)
type ServiceEmail interface {
    EnvoyerEmail(destinataire string, sujet string, corps string) error
}

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

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
}

Dans cet exemple (repris du chapitre précédent), StubServiceEmail est un stub manuel qui implémente l'interface ServiceEmail. La méthode EnvoyerEmail du stub ne fait rien de réel (n'envoie pas d'email), mais retourne simplement une erreur configurée via le champ erreurRetournee du struct StubServiceEmail. Ce stub peut être utilisé dans les tests unitaires pour remplacer l'implémentation réelle du service d'email (ServiceEmailReel) et contrôler les retours de la dépendance externe.

Mocks (Objets Simulés) : Vérifier les interactions avec les dépendances

Les mocks (objets simulés) vont au-delà des stubs en permettant non seulement de remplacer les dépendances externes, mais aussi de vérifier les interactions (appels de méthodes, arguments, nombre d'appels, ordre des appels) entre l'unité de code testée et ses dépendances. Les mocks sont utilisés pour tester non seulement le comportement de l'unité de code, mais aussi ses collaborations avec les dépendances externes.

Rôle et avantages des Mocks :

  • Vérification des interactions : Les mocks permettent de vérifier si l'unité de code testée interagit correctement avec ses dépendances externes, en vérifiant si certaines méthodes ou fonctions de la dépendance externe sont appelées correctement (avec les bons arguments, le bon nombre de fois, dans le bon ordre). Les mocks permettent de tester les hypothèses d'interaction entre les composants et de s'assurer que l'unité de code utilise ses dépendances externes comme prévu.
  • Test du comportement en fonction des interactions : Les mocks permettent de configurer le comportement des dépendances externes en fonction des interactions (des appels de méthodes) qui leur sont faits. Vous pouvez définir des expectations (attentes) sur les appels de méthodes de la dépendance externe, en spécifiant les arguments attendus, le nombre d'appels attendus, et les valeurs de retour à simuler pour chaque appel. Les mocks permettent de tester différents scénarios et cas d'utilisation en contrôlant non seulement les inputs, mais aussi le comportement des dépendances en fonction des interactions.
  • Testabilité du code orienté objet et des interactions complexes : Les mocks sont particulièrement utiles pour tester du code orienté objet et des interactions complexes entre les objets et leurs dépendances. Ils permettent de tester les collaborations entre les objets, les séquences d'appels de méthodes, les interactions basées sur des interfaces, et de valider les aspects architecturaux et de conception du code.

Framework de Mocking Go : GoMock

Pour simplifier la création et la gestion des mocks en Go, vous pouvez utiliser un framework de mocking comme GoMock (officiel, go.uber.org/mock/gomock). GoMock est un framework de mocking puissant et largement utilisé en Go, qui permet de générer automatiquement du code de mock à partir d'interfaces Go, de définir des expectations sur les mocks, et de vérifier les interactions avec les mocks lors des tests.

Workflow de Mocking avec GoMock :

  1. Définir une interface pour la dépendance externe : Si ce n'est pas déjà fait, définissez une interface Go qui décrit le comportement de la dépendance externe que vous souhaitez mocker (par exemple, ServiceEmail dans l'exemple précédent).
  2. Générer le code de mock avec mockgen : Utilisez l'outil mockgen (fourni par GoMock) pour générer automatiquement le code de mock à partir de l'interface de la dépendance externe. mockgen prend en entrée le chemin vers l'interface Go et produit un fichier .go contenant le code de mock correspondant. La commande go generate peut être utilisée pour automatiser la génération des mocks.
  3. Créer une instance du mock dans votre test : Dans votre fonction de test unitaire, créez une instance du mock généré (par exemple, mockServiceEmail := NewMockServiceEmail(ctrl), où ctrl est un contrôleur GoMock *gomock.Controller).
  4. Définir des expectations (attentes) sur le mock : Utilisez les méthodes du mock généré (méthodes EXPECT()...) pour définir les comportements attendus du mock lors du test. Spécifiez les appels de méthodes attendus, les arguments attendus, les valeurs de retour à simuler, et le nombre d'appels attendus.
  5. Injecter le mock dans le code testé : Injectez l'instance du mock dans l'unité de code que vous souhaitez tester, en remplaçant l'implémentation réelle de la dépendance par le mock (généralement via l'injection de dépendances par interface).
  6. Exécuter le code testé et vérifier les assertions : Exécutez le code testé qui interagit avec le mock. Vérifiez les assertions sur le comportement de l'unité de code testée (comme pour les tests unitaires classiques). En plus des assertions sur le comportement de l'unité de code, GoMock vérifie automatiquement si les attentes (expectations) définies sur le mock ont été respectées lors de l'exécution du test. Si une attente n'est pas respectée (méthode non appelée, arguments incorrects, nombre d'appels incorrect, etc.), GoMock signale une erreur de test.

Exemple de Mocking avec GoMock (avec l'interface ServiceEmail et la fonction InscrireUtilisateur) :

package utilisateur_test

import (
    "errors"
    "fmt"
    "testing"

    "go.uber.org/mock/gomock"
    "utilisateur" // Package contenant l'interface 'ServiceEmail' et la fonction 'InscrireUtilisateur'
    "utilisateur/mocks" // Package contenant le mock généré par GoMock
)

func TestInscrireUtilisateurMockGomock(t *testing.T) {
    ctrl := gomock.NewController(t) // Création du contrôleur GoMock
    defer ctrl.Finish()             // Vérification des attentes à la fin du test (defer ctrl.Finish())

    mockServiceEmail := mocks.NewMockServiceEmail(ctrl) // Création d'une instance du mock 'MockServiceEmail' généré par GoMock

    // Cas de test 1 : Inscription réussie, MockServiceEmail configuré pour simuler un envoi d'email réussi (pas d'erreur)
    mockServiceEmail.EXPECT().EnvoyerEmail(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1) // Définition de l'expectation : la méthode 'EnvoyerEmail' doit être appelée 1 fois, arguments Any (peu importe), retourner nil
    err1 := utilisateur.InscrireUtilisateur("Alice", "alice@example.com", mockServiceEmail)
    if err1 != nil {
        t.Errorf("Test Cas Succès (Mock) é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 mock)
    erreurEmailSimulee := errors.New("Erreur simulée d'envoi d'email (Mock)")
    mockServiceEmail.EXPECT().EnvoyerEmail(gomock.Any(), gomock.Any(), gomock.Any()).Return(erreurEmailSimulee).Times(1) // Définition de l'expectation : 'EnvoyerEmail' doit être appelée 1 fois, retourner erreurEmailSimulee
    err2 := utilisateur.InscrireUtilisateur("Bob", "bob@example.com", mockServiceEmail)
    if err2 == nil {
        t.Errorf("Test Cas Erreur (Mock) échoué : Inscription devrait échouer (erreur email), mais pas d'erreur retournée")
    }
    if !errors.Is(err2, erreurEmailSimulee) {
        t.Errorf("Test Cas Erreur (Mock) échoué : Erreur retournée devrait être de type erreurEmailSimulee, mais obtenu %v", err2)
    }
}

Cet exemple illustre l'utilisation de GoMock pour mocker le service d'email ServiceEmail lors des tests unitaires de la fonction InscrireUtilisateur. Le code de mock est généré automatiquement par mockgen à partir de l'interface ServiceEmail. Dans les tests, une instance du mock MockServiceEmail est créée, des expectations sont définies sur les appels de méthodes du mock (mockServiceEmail.EXPECT().EnvoyerEmail(...).Return(...).Times(1)), et le mock est injecté dans la fonction InscrireUtilisateur lors du test. GoMock vérifie automatiquement si les attentes définies sur le mock ont été respectées lors de l'exécution du test, permettant de tester l'interaction entre InscrireUtilisateur et sa dépendance externe (le service d'email) de manière isolée et contrôlée.

Bonnes pratiques pour le Mocking et le Stubbing

Pour utiliser efficacement le mocking et le stubbing dans vos tests unitaires Go, et écrire des tests de qualité, voici quelques bonnes pratiques à suivre :

  • Utiliser les interfaces pour abstraire les dépendances externes : Définissez des interfaces Go pour abstraire les dépendances externes de votre code. Les interfaces permettent de définir un contrat de comportement pour les dépendances et de faciliter le remplacement des implémentations réelles par des mocks ou des stubs lors des tests. L'injection de dépendances via les interfaces est une pratique essentielle pour la testabilité et le mocking.
  • Choisir entre Stubs et Mocks en fonction des besoins du test : Choisissez la technique de remplacement de dépendance la plus appropriée en fonction de ce que vous souhaitez tester :
    • Utiliser les Stubs lorsque vous avez principalement besoin de contrôler les inputs (les valeurs de retour) des dépendances externes, et que vous ne vous souciez pas de vérifier les interactions avec les dépendances. Les stubs sont plus simples à implémenter et à utiliser, et sont suffisants pour de nombreux cas de test.
    • Utiliser les Mocks lorsque vous avez besoin de vérifier les interactions (les appels de méthodes, les arguments, le nombre d'appels, l'ordre des appels) entre l'unité de code testée et ses dépendances externes. Les mocks sont plus puissants et plus expressifs que les stubs, mais ils sont aussi potentiellement plus complexes à mettre en place et à maintenir.
  • Utiliser un framework de mocking (comme GoMock) pour simplifier la création et la gestion des mocks : Pour les cas complexes ou pour simplifier la gestion des mocks, utilisez un framework de mocking Go comme GoMock. Les frameworks de mocking automatisent la génération du code de mock, la définition des attentes, et la vérification des interactions, réduisant ainsi la quantité de code boilerplate à écrire manuellement et améliorant la productivité des tests.
  • Ne mocker que les dépendances externes (interfaces) : Mocker uniquement les dépendances externes de votre unité de code (les interfaces). Evitez de mocker le code que vous testez directement (les types concrets de votre propre package) ou les types de données de base (strings, entiers, etc.). Le mocking doit servir à isoler les interactions avec les dépendances externes, et non à mocker la logique interne de votre propre code.
  • Ecrire des tests lisibles et maintenables avec des mocks et des stubs : Concevez vos tests unitaires avec des mocks et des stubs de manière à ce qu'ils restent lisibles, compréhensibles et maintenables. Utilisez des noms de mocks et de stubs clairs et descriptifs. Documentez clairement les attentes définies sur les mocks et le comportement simulé des stubs. Evitez les tests trop complexes ou trop longs qui utilisent un nombre excessif de mocks ou de stubs, car cela peut rendre les tests difficiles à comprendre et à maintenir.
  • Valider le comportement, pas l'implémentation (même avec les mocks) : Même lorsque vous utilisez des mocks pour vérifier les interactions, concentrez-vous toujours sur la validation du comportement attendu de l'unité de code testée (ce que fait l'unité de code, quel est son résultat, comment elle réagit à différentes entrées et interactions avec les dépendances), plutôt que sur les détails d'implémentation des mocks (quels méthodes sont appelées exactement, combien de fois, avec quels arguments précis). Les tests unitaires doivent valider le comportement observable et spécifié de l'unité de code, et non l'implémentation interne des mocks ou des dépendances.

En appliquant ces bonnes pratiques, vous maîtriserez le mocking et le stubbing en Go et écrirez des tests unitaires isolés, ciblés, robustes, et faciles à maintenir, en améliorant significativement la qualité et la testabilité de votre code Go.