1. Introduction aux tests unitaires

Les tests unitaires consistent à vérifier et valider l'unité de test la plus petite dans un programme, telle qu'une fonction ou une méthode dans le langage Go. Les tests unitaires garantissent que le code fonctionne comme prévu et permettent aux développeurs d'apporter des modifications au code sans casser accidentellement les fonctionnalités existantes.

Dans un projet Golang, l'importance des tests unitaires va sans dire. Tout d'abord, ils peuvent améliorer la qualité du code, donnant aux développeurs plus de confiance pour apporter des changements au code. Deuxièmement, les tests unitaires peuvent servir de documentation pour le code, expliquant son comportement attendu. De plus, l'exécution automatique des tests unitaires dans un environnement d'intégration continue peut découvrir rapidement les bogues nouvellement introduits, améliorant ainsi la stabilité du logiciel.

2. Réalisation de tests de base à l'aide du package testing

La bibliothèque standard du langage Go comprend le package testing, qui fournit des outils et des fonctionnalités pour écrire et exécuter des tests.

2.1 Création de votre premier cas de test

Pour écrire une fonction de test, vous devez créer un fichier avec le suffixe _test.go. Par exemple, si votre fichier de code source est nommé calculator.go, votre fichier de test doit être nommé calculator_test.go.

Ensuite, il est temps de créer la fonction de test. Une fonction de test doit importer le package testing et suivre un certain format. Voici un exemple simple :

// calculator_test.go
package calculator

import (
	"testing"
	"fmt"
)

// Tester la fonction d'addition
func TestAdd(t *testing.T) {
	resultat := Add(1, 2)
	attendu := 3

if resultat != attendu {
		t.Errorf("Attendu %v, mais obtenu %v", attendu, resultat)
	}
}

Dans cet exemple, TestAdd est une fonction de test qui teste une fonction d'addition imaginaire. Si le résultat de la fonction Add correspond au résultat attendu, le test réussira ; sinon, t.Errorf sera appelé pour enregistrer les informations sur l'échec du test.

2.2 Comprendre les règles de dénomination et la signature des fonctions de test

Les fonctions de test doivent commencer par Test, suivi de n'importe quelle chaîne non minuscule, et leur seul paramètre doit être un pointeur vers testing.T. Comme le montre l'exemple, TestAdd suit les règles de dénomination et la signature correcte.

2.3 Exécution des cas de test

Vous pouvez exécuter vos cas de test à l'aide de l'outil en ligne de commande. Pour un cas de test spécifique, exécutez la commande suivante :

go test -v // Exécute les tests dans le répertoire courant et affiche une sortie détaillée

Si vous souhaitez exécuter un cas de test spécifique, vous pouvez utiliser le drapeau -run suivi d'une expression régulière :

go test -v -run TestAdd // Exécute uniquement la fonction de test TestAdd

La commande go test trouvera automatiquement tous les fichiers _test.go et exécutera chaque fonction de test qui répond aux critères. Si tous les tests réussissent, vous verrez un message similaire à PASS dans la ligne de commande ; si un test échoue, vous verrez FAIL accompagné du message d'erreur correspondant.

3. Écriture des cas de test

3.1 Signalement des erreurs à l'aide de t.Errorf et t.Fatalf

En langage Go, le framework de tests fournit diverses méthodes pour signaler les erreurs. Les deux fonctions les plus couramment utilisées sont Errorf et Fatalf, toutes deux étant des méthodes de l'objet testing.T. Errorf est utilisé pour signaler les erreurs dans le test mais ne stoppe pas le cas de test en cours, tandis que Fatalf arrête immédiatement le test en cours après avoir signalé une erreur. Il est important de choisir la méthode appropriée en fonction des exigences de test.

Exemple d'utilisation de Errorf :

func TestAdd(t *testing.T) {
    obtenu := Add(1, 2)
    attendu := 3
    if obtenu != attendu {
        t.Errorf("Add(1, 2) = %d ; attendu %d", obtenu, attendu)
    }
}

Si vous souhaitez arrêter immédiatement le test lorsqu'une erreur est détectée, vous pouvez utiliser Fatalf :

func TestSubtract(t *testing.T) {
    obtenu := Subtract(5, 3)
    if obtenu != 2 {
        t.Fatalf("Subtract(5, 3) = %d ; attendu 2", obtenu)
    }
}

En général, si l'erreur entraînerait un dysfonctionnement du code ultérieur ou si l'échec du test peut être confirmé à l'avance, il est recommandé d'utiliser Fatalf. Sinon, il est recommandé d'utiliser Errorf pour obtenir un résultat de test plus complet.

3.2 Organisation des sous-tests et exécution des sous-tests

En Go, nous pouvons utiliser t.Run pour organiser les sous-tests, ce qui nous aide à écrire du code de test de manière plus structurée. Les sous-tests peuvent avoir leur propre Setup et Teardown et peuvent être exécutés individuellement, offrant ainsi une grande flexibilité. C'est particulièrement utile pour réaliser des tests complexes ou des tests paramétrés.

Exemple d'utilisation du sous-test t.Run :

func TestMultiply(t *testing.T) {
    testcases := []struct {
        name           string
        a, b, expected int
    }{
        {"2x3", 2, 3, 6},
        {"-1x-1", -1, -1, 1},
        {"0x4", 0, 4, 0},
    }

    for _, tc := range testcases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Multiply(tc.a, tc.b); got != tc.expected {
                t.Errorf("Multiply(%d, %d) = %d; attendu %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

Si nous voulons exécuter le sous-test nommé "2x3" individuellement, nous pouvons exécuter la commande suivante dans la ligne de commande :

go test -run TestMultiply/2x3

Veuillez noter que les noms des sous-tests sont sensibles à la casse.

4. Travaux préparatoires avant et après les tests

4.1 Setup et Teardown

Lors de la réalisation de tests, nous devons souvent préparer un état initial pour les tests (comme la connexion à la base de données, la création de fichiers, etc.), et de même, nous devons effectuer un travail de nettoyage après la fin des tests. En Go, nous effectuons généralement Setup et Teardown directement dans les fonctions de test, et la fonction t.Cleanup nous permet d'enregistrer des fonctions de rappel de nettoyage.

Voici un exemple simple :

func TestDatabase(t *testing.T) {
    db, err := SetupDatabase()
    if err != nil {
        t.Fatalf("configuration échouée : %v", err)
    }

    // Enregistrer un rappel de nettoyage pour garantir que la connexion à la base de données est fermée lorsque le test est terminé
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("échec de la fermeture de la base de données : %v", err)
        }
    })

    // Effectuer le test...
}

Dans la fonction TestDatabase, nous appelons d'abord la fonction SetupDatabase pour configurer l'environnement de test. Ensuite, nous utilisons t.Cleanup() pour enregistrer une fonction qui sera appelée après la fin du test pour effectuer un travail de nettoyage, dans cet exemple, c'est la fermeture de la connexion à la base de données. De cette façon, nous pouvons nous assurer que les ressources sont libérées correctement, que le test réussisse ou échoue.

5. Amélioration de l'efficacité des tests

Améliorer l'efficacité des tests peut nous aider à itérer plus rapidement dans le développement, à découvrir rapidement les problèmes et à garantir la qualité du code. Ci-dessous, nous discuterons de la couverture des tests, des tests basés sur des tableaux et de l'utilisation de mocks pour améliorer l'efficacité des tests.

5.1 Couverture des tests et outils associés

L'outil go test fournit une fonctionnalité très utile de couverture des tests, qui nous aide à comprendre quels parties du code sont couvertes par les cas de test, découvrant ainsi les parties du code qui ne sont pas couvertes par les tests.

En utilisant la commande go test -cover, vous pouvez voir le pourcentage actuel de couverture des tests :

go test -cover

Si vous souhaitez avoir une compréhension plus détaillée des lignes de code exécutées et de celles qui ne le sont pas, vous pouvez utiliser le paramètre -coverprofile, qui génère un fichier de données de couverture. Ensuite, vous pouvez utiliser la commande go tool cover pour générer un rapport détaillé sur la couverture des tests.

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

La commande ci-dessus ouvrira un rapport web, montrant visuellement quelles lignes de code sont couvertes par les tests et lesquelles ne le sont pas. Le vert représente le code couvert par les tests, tandis que le rouge représente les lignes de code non couvertes.

5.2 Utilisation des Mocks

En matière de tests, nous rencontrons souvent des scénarios où nous devons simuler des dépendances externes. Les mocks peuvent nous aider à simuler ces dépendances, éliminant ainsi le besoin de compter sur des services externes spécifiques ou des ressources dans l'environnement de test.

Il existe de nombreux outils de mock dans la communauté Go, tels que testify/mock et gomock. Ces outils fournissent généralement une série d'API pour créer et utiliser des objets mock.

Voici un exemple basique d'utilisation de testify/mock. La première chose à faire est de définir une interface et sa version mock :

type DataService interface {
    FetchData() (int, error)
}

type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) FetchData() (int, error) {
    args := m.Called()
    return args.Int(0), args.Error(1)
}

En test, nous pouvons utiliser MockDataService pour remplacer le service de données réel :

func TestSomething(t *testing.T) {
    mockDataSvc := new(MockDataService)
    mockDataSvc.On("FetchData").Return(42, nil) // Configuration du comportement attendu

    result, err := mockDataSvc.FetchData() // Utilisation de l'objet mock
    assert.NoError(t, err)
    assert.Equal(t, 42, result)

    mockDataSvc.AssertExpectations(t) // Vérification si le comportement attendu s'est produit
}

Grâce à cette approche, nous pouvons éviter de dépendre des services externes ou de mettre en isolation des appels de base de données, etc. dans les tests. Cela peut accélérer l'exécution des tests et rendre nos tests plus stables et fiables.

6. Techniques de Test Avancées

Après avoir maîtrisé les bases des tests unitaires en Go, nous pouvons explorer plus en détail certaines techniques de test avancées, qui aident à construire un logiciel plus robuste et à améliorer l'efficacité des tests.

6.1 Testing des Fonctions Privées

En Go, les fonctions privées font généralement référence à des fonctions non exportées, c'est-à-dire des fonctions dont les noms commencent par une lettre minuscule. En général, nous préférons tester les interfaces publiques, car elles reflètent l'utilité du code. Cependant, il existe des cas où tester directement les fonctions privées a également du sens, par exemple lorsque la fonction privée a une logique complexe et est appelée par plusieurs fonctions publiques.

Tester les fonctions privées diffère du test des fonctions publiques car elles ne peuvent pas être accédées depuis l'extérieur du package. Une technique courante consiste à écrire le code de test dans le même package, permettant ainsi l'accès à la fonction privée.

Voici un exemple simple :

// calculator.go
package calculator

func add(a, b int) int {
    return a + b
}

Le fichier de test correspondant est le suivant :

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    expected := 4
    actual := add(2, 2)
    if actual != expected {
        t.Errorf("attendu %d, obtenu %d", expected, actual)
    }
}

En plaçant le fichier de test dans le même package, nous pouvons tester directement la fonction add.

6.2 Modèles de Test Courants et Meilleures Pratiques

Les tests unitaires en Go bénéficient de certains modèles courants qui facilitent le travail de test et contribuent à maintenir la clarté et la maintenabilité du code.

  1. Tests basés sur des tables

    Les tests basés sur des tables sont une méthode d'organisation des entrées de test et des sorties attendues. En définissant un ensemble de cas de test, puis en les parcourant pour les tester, cette méthode rend très facile l'ajout de nouveaux cas de test et rend également le code plus facile à lire et à maintenir.

    // calculator_test.go
    package calculator

    import "testing"

    func TestAddTableDriven(t *testing.T) {
        var tests = []struct {
            a, b   int
            want   int
        }{
            {1, 2, 3},
            {2, 2, 4},
            {5, -1, 4},
        }

        for _, tt := range tests {
            testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
            t.Run(testname, func(t *testing.T) {
                ans := add(tt.a, tt.b)
                if tt.want != ans {
                    t.Errorf("obtenu %d, attendu %d", ans, tt.want)
                }
            })
        }
    }
  1. Utilisation de Mocks pour les Tests

    Le mocking est une technique de test qui consiste à remplacer des dépendances pour tester différentes parties de la fonctionnalité. En Go, les interfaces sont la principale façon de mettre en œuvre des mocks. En utilisant des interfaces, une implémentation mock peut être créée puis utilisée en test.