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.