Aller au contenu

Tests en Go

Go intégré un framework de test complet dans sa bibliotheque standard via le package testing. Aucun outil externe n'est nécessaire pour écrire des tests unitaires, des benchmarks ou du fuzzing. Les bibliotheques tierces comme testify ajoutent de la lisibilite, tandis que httptest permet de tester les handlers HTTP sans serveur réel.


Comparatif des outils de test

Outil Type Points forts Usage typique
testing Stdlib Zero dépendance, benchmarks, fuzzing natif Tous les projets Go
testify Assertions assert/require clairs, suite de tests, mocks Lisibilite, reduce boilerplate
gomock Mocking Génération automatique de mocks depuis interfaces Isolation des dépendances
httptest Test HTTP Recorder, serveur de test, sans port réel Tests de handlers HTTP
go-cmp Comparaison Diff lisible entre structs complexes Comparaison de valeurs profondes

Tests unitaires natifs

// handlers_test.go
// Lancement : go test ./...
// Lancement verbose : go test -v ./...
package main

import (
    "testing"
)

// TestSomme est un test simple du package testing.
// Le nom doit commencer par Test suivi d'une majuscule.
func TestSomme(t *testing.T) {
    got := somme(2, 3)
    want := 5
    if got != want {
        // t.Errorf continue l'execution, t.Fatalf l'arrete
        t.Errorf("somme(2, 3) = %d ; attendu %d", got, want)
    }
}

// somme est la fonction testee (dans le meme package pour les tests boite blanche).
func somme(a, b int) int {
    return a + b
}

Tests table-driven

Les tests table-driven sont l'idiome Go pour tester plusieurs cas sans répétition. Ils sont recommandes par Effective Go.

package main

import "testing"

func TestDivision(t *testing.T) {
    // Chaque cas est une struct anonyme dans une slice
    tests := []struct {
        name      string
        a, b      float64
        want      float64
        wantError bool
    }{
        {"division normale", 10, 2, 5, false},
        {"division par un", 7, 1, 7, false},
        {"resultat decimal", 1, 3, 0.3333333333333333, false},
        {"division par zero", 10, 0, 0, true},
        {"dividende negatif", -6, 2, -3, false},
    }

    for _, tc := range tests {
        // t.Run cree un sous-test nomme — visible avec go test -v
        t.Run(tc.name, func(t *testing.T) {
            got, err := diviser(tc.a, tc.b)

            if tc.wantError {
                if err == nil {
                    t.Error("erreur attendue, aucune obtenue")
                }
                return
            }
            if err != nil {
                t.Fatalf("erreur inattendue : %v", err)
            }
            if got != tc.want {
                t.Errorf("diviser(%v, %v) = %v ; attendu %v", tc.a, tc.b, got, tc.want)
            }
        })
    }
}

Tests HTTP avec httptest

httptest.NewRecorder permet de tester les handlers Gin sans démarrer de serveur.

// handlers_http_test.go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// setupTestRouter cree un routeur Gin avec une base SQLite en memoire.
func setupTestRouter(t *testing.T) *gin.Engine {
    t.Helper()
    gin.SetMode(gin.TestMode) // Desactive les logs Gin pendant les tests

    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    if err != nil {
        t.Fatalf("Impossible d'ouvrir la base de test : %v", err)
    }
    if err := db.AutoMigrate(&Item{}); err != nil {
        t.Fatalf("Migration echouee : %v", err)
    }

    h := &Handler{DB: db}
    r := gin.New()
    items := r.Group("/items")
    items.GET("", h.ListItems)
    items.POST("", h.CreateItem)
    items.GET("/:id", h.GetItem)
    items.PUT("/:id", h.UpdateItem)
    items.DELETE("/:id", h.DeleteItem)
    return r
}

func TestListItemsVide(t *testing.T) {
    r := setupTestRouter(t)

    w := httptest.NewRecorder()
    req, _ := http.NewRequest(http.MethodGet, "/items", nil)
    r.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Errorf("code = %d ; attendu %d", w.Code, http.StatusOK)
    }

    var items []Item
    if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
        t.Fatalf("Impossible de deserialiser la reponse : %v", err)
    }
    if len(items) != 0 {
        t.Errorf("len(items) = %d ; attendu 0", len(items))
    }
}

func TestCreateItem(t *testing.T) {
    r := setupTestRouter(t)

    payload := map[string]any{"name": "Widget", "price": 9.99, "stock": 10}
    body, _ := json.Marshal(payload)

    w := httptest.NewRecorder()
    req, _ := http.NewRequest(http.MethodPost, "/items", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    r.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Errorf("code = %d ; attendu %d — body: %s", w.Code, http.StatusCreated, w.Body.String())
    }
}

func TestCreateItemPrixInvalide(t *testing.T) {
    r := setupTestRouter(t)

    prixInvalides := []float64{-1, 0, -100.5}
    for _, prix := range prixInvalides {
        t.Run("prix invalide", func(t *testing.T) {
            payload := map[string]any{"name": "Widget", "price": prix}
            body, _ := json.Marshal(payload)

            w := httptest.NewRecorder()
            req, _ := http.NewRequest(http.MethodPost, "/items", bytes.NewBuffer(body))
            req.Header.Set("Content-Type", "application/json")
            r.ServeHTTP(w, req)

            if w.Code != http.StatusBadRequest {
                t.Errorf("prix %v : code = %d ; attendu %d", prix, w.Code, http.StatusBadRequest)
            }
        })
    }
}

func TestGetItemInexistant(t *testing.T) {
    r := setupTestRouter(t)

    w := httptest.NewRecorder()
    req, _ := http.NewRequest(http.MethodGet, "/items/999", nil)
    r.ServeHTTP(w, req)

    if w.Code != http.StatusNotFound {
        t.Errorf("code = %d ; attendu %d", w.Code, http.StatusNotFound)
    }
}

Mocking avec interfaces

Go favorise les interfaces implicites pour l'injection de dépendances. Un mock est simplement une struct qui implémenté l'interface.

// store.go — Interface de stockage
package main

import "context"

// ItemStore definit le contrat de persistance.
// Toute struct implementant ces methodes satisfait l'interface.
type ItemStore interface {
    FindByID(ctx context.Context, id int) (*Item, error)
    Save(ctx context.Context, item *Item) error
}

// MockItemStore est un mock manuel de ItemStore pour les tests.
type MockItemStore struct {
    items map[int]*Item
}

func NewMockItemStore() *MockItemStore {
    return &MockItemStore{items: make(map[int]*Item)}
}

func (m *MockItemStore) FindByID(_ context.Context, id int) (*Item, error) {
    item, ok := m.items[id]
    if !ok {
        return nil, ErrNotFound
    }
    return item, nil
}

func (m *MockItemStore) Save(_ context.Context, item *Item) error {
    m.items[int(item.ID)] = item
    return nil
}

Benchmarks

Les benchmarks sont écrits dans les fichiers _test.go avec la signature BenchmarkXxx(b *testing.B).

// bench_test.go
package main

import (
    "strings"
    "testing"
)

// BenchmarkConcatenation compare deux strategies de concatenation de chaines.
func BenchmarkConcatenationNaive(b *testing.B) {
    // b.N est ajuste automatiquement par le framework
    for i := 0; i < b.N; i++ {
        result := ""
        for j := 0; j < 100; j++ {
            result += "x"
        }
        _ = result
    }
}

func BenchmarkConcatenationBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}
# Lancement des benchmarks
go test -bench=. -benchmem ./...

# Exemple de sortie
# BenchmarkConcatenationNaive-8     500000    2345 ns/op    5600 B/op   99 allocs/op
# BenchmarkConcatenationBuilder-8  5000000     234 ns/op     224 B/op    2 allocs/op

Fuzzing natif (Go 1.18+)

Le fuzzing natif généré automatiquement des entrees pour trouver des panics ou des comportements inattendus.

// fuzz_test.go
package main

import (
    "fmt"
    "strings"
    "testing"
    "unicode/utf8"
)

// FuzzParseItem teste que le parsing ne panique jamais sur une entree arbitraire.
// Les corpus initiaux (seed) guident le fuzzer.
func FuzzParseNom(f *testing.F) {
    // Corpus initial — cas connus
    f.Add("Widget")
    f.Add("")
    f.Add("Produit avec espaces")
    f.Add("Nom-tres-long-" + strings.Repeat("x", 200))

    f.Fuzz(func(t *testing.T, nom string) {
        // La fonction ne doit jamais paniquer
        // Elle doit retourner une erreur propre pour les entrees invalides
        if !utf8.ValidString(nom) {
            t.Skip() // Ignore les entrees non-UTF-8
        }
        _ = validerNom(nom) // validerNom est la fonction testee
    })
}

func validerNom(nom string) error {
    if len(nom) == 0 {
        return fmt.Errorf("le nom ne peut pas etre vide")
    }
    if len(nom) > 200 {
        return fmt.Errorf("le nom depasse 200 caracteres")
    }
    return nil
}
# Mode fuzzing actif (cherche des bugs pendant N secondes)
go test -fuzz=FuzzParseNom -fuzztime=30s

# Rejouer uniquement le corpus existant (mode CI)
go test -run=FuzzParseNom ./...

Couverture

# Couverture dans le terminal
go test -cover ./...

# Profil de couverture detaille
go test -coverprofile=coverage.out ./...

# Rapport HTML interactif
go tool cover -html=coverage.out

# Seuil de couverture en CI (echoue si < 80%)
go test -cover ./... | grep -E 'coverage: [0-9]+\.[0-9]+%' | \
  awk '{if ($2+0 < 80) exit 1}'

Couverture par package

go test -coverprofile couvre le package teste par défaut. Pour une couverture tous packages : go test -coverprofile=out -coverpkg=./... ./....

La couverture ne remplacé pas la qualité

Un taux de couverture de 80% avec des assertions vides (_ = result) est sans valeur. Privilegiez des tests qui échouent si le comportement change plutôt que des tests qui maximisent mecaniquement la couverture.