Test unitaires en Go : Structure et bonnes pratiques

Le test en Go des bases aux modèles avancés

Sommaire

Le package de test intégré à Go fournit un cadre puissant et minimaliste pour écrire des tests unitaires sans dépendances externes. Voici les fondamentaux des tests, la structure du projet et les modèles avancés pour construire des applications Go fiables.

Les tests unitaires en Go sont géniaux

Pourquoi les tests sont importants en Go

La philosophie de Go met l’accent sur la simplicité et la fiabilité. La bibliothèque standard inclut le package testing, rendant les tests unitaires un citoyen de première classe dans l’écosystème Go. Le code Go bien testé améliore la maintenabilité, détecte les bugs tôt et fournit une documentation via des exemples. Si vous êtes nouveau en Go, consultez notre Feuille de triche Go pour un résumé rapide des fondamentaux du langage.

Les avantages principaux des tests en Go :

  • Support intégré : Aucun framework externe requis
  • Exécution rapide : Exécution par défaut en parallèle
  • Syntaxe simple : Peu de code de mise en place
  • Outils riches : Rapports de couverture, benchmarks et profilage
  • Ami des pipelines CI/CD : Intégration facile avec les pipelines automatisés

Structure du projet pour les tests Go

Les tests Go vivent à côté de votre code de production avec une convention de nommage claire :

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Conventions clés :

  • Les fichiers de test se terminent par _test.go
  • Les tests sont dans le même package que le code (ou utilisent le suffixe _test pour les tests boîte noire)
  • Chaque fichier source peut avoir un fichier de test correspondant

Approches de test par package

Test boîte blanche (même package) :

package calculator

import "testing"
// Peut accéder aux fonctions et variables non exportées

Test boîte noire (package externe) :

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Peut accéder uniquement aux fonctions exportées (recommandé pour les API publiques)

Structure de base des tests

Chaque fonction de test suit ce modèle :

package calculator

import "testing"

// La fonction de test doit commencer par "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Méthodes de testing.T :

  • t.Error() / t.Errorf() : Marque le test comme échoué mais continue
  • t.Fatal() / t.Fatalf() : Marque le test comme échoué et s’arrête immédiatement
  • t.Log() / t.Logf() : Journalisation (affichée uniquement avec le drapeau -v)
  • t.Skip() / t.Skipf() : Ignorer le test
  • t.Parallel() : Exécuter le test en parallèle avec d’autres tests parallèles

Tests basés sur des tableaux : La manière Go

Les tests basés sur des tableaux sont l’approche idiomatique en Go pour tester plusieurs scénarios. Avec les générics Go, vous pouvez également créer des helpers de test type-sûrs qui fonctionnent avec différents types de données :

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"addition", 2, 3, "+", 5, false},
        {"subtraction", 5, 3, "-", 2, false},
        {"multiplication", 4, 3, "*", 12, false},
        {"division", 10, 2, "/", 5, false},
        {"division par zéro", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

Avantages :

  • Une seule fonction de test pour plusieurs scénarios
  • Facile à ajouter de nouveaux cas de test
  • Documentation claire du comportement attendu
  • Meilleure organisation et maintenabilité des tests

Exécution des tests

Commandes de base

# Exécuter les tests dans le répertoire actuel
go test

# Exécuter les tests avec sortie détaillée
go test -v

# Exécuter les tests dans tous les sous-répertoires
go test ./...

# Exécuter un test spécifique
go test -run TestAdd

# Exécuter des tests correspondant à un motif
go test -run TestCalculate/addition

# Exécuter les tests en parallèle (par défaut GOMAXPROCS)
go test -parallel 4

# Exécuter les tests avec un délai
go test -timeout 30s

Couverture des tests

# Exécuter les tests avec couverture
go test -cover

# Générer le profil de couverture
go test -coverprofile=coverage.out

# Afficher la couverture dans le navigateur
go tool cover -html=coverage.out

# Afficher la couverture par fonction
go tool cover -func=coverage.out

# Définir le mode de couverture (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Drapeaux utiles

  • -short : Exécuter les tests marqués avec if testing.Short()
  • -race : Activer le détecteur de course (détecte les problèmes d’accès concurrent)
  • -cpu : Spécifier les valeurs de GOMAXPROCS
  • -count n : Exécuter chaque test n fois
  • -failfast : Arrêter au premier échec de test

Helpers de test et configuration/Nettoyage

Fonctions helpers

Marquer les fonctions helpers avec t.Helper() pour améliorer le rapport d’erreur :

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Cette ligne est rapportée comme l'appelant
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // La ligne d'erreur pointe ici
}

Configuration et nettoyage

func TestMain(m *testing.M) {
    // Code de configuration
    setup()
    
    // Exécuter les tests
    code := m.Run()
    
    // Code de nettoyage
    teardown()
    
    os.Exit(code)
}

Fixtures de test

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Code de test ici
}

Mocking et injection de dépendances

Mocking basé sur les interfaces

Lorsque vous testez du code qui interagit avec des bases de données, l’utilisation d’interfaces facilite la création d’implémentations de mock. Si vous travaillez avec PostgreSQL en Go, consultez notre comparaison des ORMs Go pour choisir la bonne bibliothèque de base de données avec une bonne testabilité.

// Code de production
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Code de test
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("erreur inattendue : %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Bibliothèques de test populaires

Testify

La bibliothèque de test la plus populaire en Go pour les affirmations et les mocks :

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "ils devraient être égaux")
    assert.NotNil(t, result)
}

// Exemple de mock
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Autres outils

  • gomock : Framework de mocking de Google avec génération de code
  • httptest : Bibliothèque standard pour tester les gestionnaires HTTP
  • testcontainers-go : Tests d’intégration avec des conteneurs Docker
  • ginkgo/gomega : Framework de test en style BDD

Lorsque vous testez des intégrations avec des services externes comme des modèles d’IA, vous devrez créer des mocks ou des stubs pour ces dépendances. Par exemple, si vous utilisez Ollama en Go, envisagez de créer des wrappers d’interface pour rendre votre code plus testable.

Tests de benchmark

Go inclut un support intégré pour les benchmarks :

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Exécuter les benchmarks
// go test -bench=. -benchmem

La sortie affiche les itérations par seconde et les allocations de mémoire.

Bonnes pratiques

  1. Écrivez des tests basés sur des tableaux : Utilisez le modèle de slice de structs pour plusieurs cas de test
  2. Utilisez t.Run pour les sous-tests : Meilleure organisation et possibilité d’exécuter sélectivement les sous-tests
  3. Testez d’abord les fonctions exportées : Concentrez-vous sur le comportement de l’API publique
  4. Gardez les tests simples : Chaque test doit vérifier une seule chose
  5. Utilisez des noms de tests significatifs : Décrivez ce qui est testé et le résultat attendu
  6. Ne testez pas les détails d’implémentation : Testez le comportement, pas les internes
  7. Utilisez des interfaces pour les dépendances : Facilite le mocking
  8. Visez une forte couverture, mais qualité avant quantité : Une couverture de 100 % ne signifie pas l’absence de bugs
  9. Exécutez les tests avec le drapeau -race : Détectez les problèmes de concurrence tôt
  10. Utilisez TestMain pour les configurations coûteuses : Évitez de répéter la configuration dans chaque test

Exemple : Suite de tests complète

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("le nom ne peut pas être vide")
    }
    if u.Email == "" {
        return errors.New("l'adresse e-mail ne peut pas être vide")
    }
    return nil
}

// Fichier de test : user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "utilisateur valide",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "nom vide",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "le nom ne peut pas être vide",
        },
        {
            name:    "e-mail vide",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "l'adresse e-mail ne peut pas être vide",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Liens utiles

Conclusion

Le framework de test de Go fournit tout ce dont on a besoin pour des tests unitaires complets avec un minimum de configuration. En suivant les idiomes Go comme les tests basés sur des tableaux, en utilisant des interfaces pour le mocking et en exploitant les outils intégrés, vous pouvez créer des ensembles de tests maintenables et fiables qui évoluent avec votre codebase.

Ces pratiques de test s’appliquent à tous les types d’applications Go, des services web aux applications CLI construites avec Cobra & Viper. Le test des outils de ligne de commande nécessite des schémas similaires avec une attention particulière au test d’entrée/sortie et à l’analyse des drapeaux.

Commencez par des tests simples, ajoutez progressivement la couverture et souvenez-vous que le test est un investissement dans la qualité du code et la confiance des développeurs. L’accent mis par la communauté Go sur les tests rend plus facile la maintenance des projets à long terme et la collaboration efficace avec les membres de l’équipe.