Modern Go Engineering
Chapitre 6
06 - Testing en Go
06 - Testing en Go
Cours 06 : Testing en Go
1. Package testing
1.1 Tests unitaires de base
package main
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
// Test helper
func addHelper(t *testing.T, a, b, want int) {
t.Helper()
got := Add(a, b)
if got != want {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, got, want)
}
}
func TestAddMultiple(t *testing.T) {
addHelper(t, 1, 2, 3)
addHelper(t, -1, 1, 0)
addHelper(t, 0, 0, 0)
}
1.2 Table-driven tests
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{name: "positive", a: 10, b: 2, want: 5},
{name: "negative", a: -10, b: 2, want: -5},
{name: "zero", a: 1, b: 0, wantErr: true},
{name: "decimal", a: 7, b: 2, want: 3.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Error("expected error but got none")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Divide(%f, %f) = %f; want %f",
tt.a, tt.b, got, tt.want)
}
})
}
}
1.3 Subtests
func TestUserService(t *testing.T) {
// Setup
db := setupTestDB(t)
svc := NewUserService(db)
// Nettoyage
t.Cleanup(func() {
db.Close()
})
t.Run("create user", func(t *testing.T) {
user, err := svc.Create(context.Background(), &User{
Name: "Alice", Email: "alice@test.com",
})
if err != nil {
t.Fatal(err)
}
if user.ID == 0 {
t.Error("expected non-zero ID")
}
})
t.Run("get user", func(t *testing.T) {
// Dépend du test précédent
user, err := svc.GetByID(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("got name %s, want Alice", user.Name)
}
})
t.Run("parallel", func(t *testing.T) {
t.Parallel() // Exécuté en parallèle avec les autres tests parallèles
// ...
})
}
1.4 Test helpers
func createTestUser(t *testing.T, svc *UserService) *User {
t.Helper()
user, err := svc.Create(context.Background(), &User{
Name: "Test User",
Email: fmt.Sprintf("test-%d@test.com", time.Now().UnixNano()),
})
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}
func TestDeleteUser(t *testing.T) {
svc := setupService(t)
user := createTestUser(t, svc)
err := svc.Delete(context.Background(), user.ID)
if err != nil {
t.Errorf("Delete() error = %v", err)
}
_, err = svc.GetByID(context.Background(), user.ID)
if err == nil {
t.Error("expected error for deleted user")
}
}
1.5 TestMain
func TestMain(m *testing.M) {
// Setup global
log.Println("Setting up test suite...")
db := setupTestDatabase()
defer db.Close()
// Exécuter les tests
code := m.Run()
// Cleanup global
log.Println("Tearing down test suite...")
os.Exit(code)
}
2. Assertions (testify)
2.1 testify/require vs assert
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
result, err := DoSomething()
// assert: continue après l'erreur
assert.NoError(t, err)
assert.Equal(t, 42, result)
assert.NotNil(t, result)
assert.True(t, result.IsValid())
assert.Contains(t, result.Name, "Alice")
// require: arrête le test
require.NoError(t, err)
require.NotNil(t, result)
// continue ici...
}
3. Mocking
3.1 testify/mock
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func TestUserService_GetByID(t *testing.T) {
mockRepo := new(MockUserRepository)
svc := NewUserService(mockRepo)
expectedUser := &User{ID: 1, Name: "Alice"}
mockRepo.On("GetByID", mock.Anything, 1).
Return(expectedUser, nil)
user, err := svc.GetByID(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
mockRepo.AssertExpectations(t)
}
3.2 mockgen
# Installer mockgen
go install go.uber.org/mock/mockgen@latest
# Générer le mock
mockgen -source=interfaces.go -package=mocks -destination=mocks/repository.go
# Avec source
mockgen -source=user/repository.go -destination=user/mock_repository.go
3.3 Interface mocking pattern
//go:generate mockgen -source=$GOFILE -destination=mock_$GOFILE -package=$GOPACKAGE
type EmailService interface {
Send(ctx context.Context, to, subject, body string) error
}
4. Fuzzing (Go 1.18+)
func FuzzParsePhone(f *testing.F) {
// Seed corpus
f.Add("+33123456789")
f.Add("0123456789")
f.Add("+1 (555) 123-4567")
f.Fuzz(func(t *testing.T, input string) {
result, err := ParsePhone(input)
if err != nil {
return // Erreur attendue pour certains inputs
}
// Propriétés à vérifier
if len(result) < 10 {
t.Errorf("phone too short: %s", result)
}
if !strings.HasPrefix(result, "+") {
t.Errorf("phone missing prefix: %s", result)
}
})
}
5. Benchmarks
5.1 Benchmark basique
func BenchmarkSum(b *testing.B) {
nums := make([]int, 1000)
for i := range nums {
nums[i] = i
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sum(nums)
}
}
5.2 Benchmarks avec setup
func BenchmarkSort(b *testing.B) {
data := generateLargeSlice(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
tmp := make([]int, len(data))
copy(tmp, data)
b.StartTimer()
sort.Ints(tmp)
}
}
func BenchmarkParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
DoExpensiveCalculation()
}
})
}
5.3 Comparaison de benchmarks
func BenchmarkStringConcat(b *testing.B) {
benchmarks := []struct {
name string
fn func([]string) string
}{
{"Builder", joinBuilder},
{"Join", strings.Join},
{"Plus", joinPlus},
}
inputs := []string{"a", "b", "c", "d", "e"}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
bm.fn(inputs)
}
})
}
}
6. Profiling
// Profiling CPU
func BenchmarkProfile(b *testing.B) {
for i := 0; i < b.N; i++ {
ExpensiveFunction()
}
}
# CPU profile
go test -bench=. -cpuprofile=cpu.prof
go tool pprof cpu.prof
# Memory profile
go test -bench=. -memprofile=mem.prof
go tool pprof -alloc_space mem.prof
# Trace
go test -trace=trace.out
go tool trace trace.out
# Visualiser
go tool pprof -http=:8080 cpu.prof
7. Coverage
// Exclure du coverage (annotation)
//go:build !test
# Coverage basique
go test -cover
go test -coverprofile=coverage.out
# Coverage détaillé
go test -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out
go tool cover -html=coverage.out
# Coverage par package
go test -coverprofile=coverage.out ./...
8. Tests d'intégration
8.1 Build tags
// integration_test.go
//go:build integration
package main
import "testing"
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Test avec vraie DB...
}
# Exécuter seulement les tests unitaires
go test -short ./...
# Inclure les tests d'intégration
go test -tags=integration ./...
8.2 Testcontainers
import "github.com/testcontainers/testcontainers-go"
func TestWithPostgres(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:16",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_DB": "testdb",
"POSTGRES_PASSWORD": "test",
},
}
postgres, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
defer postgres.Terminate(ctx)
host, _ := postgres.Host(ctx)
port, _ := postgres.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/testdb?sslmode=disable",
host, port.Port())
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)
defer db.Close()
// Exécuter les migrations et les tests
runMigrations(db)
testQueries(t, db)
}
9. Golden Files
package main
import (
"flag"
"os"
"path/filepath"
"testing"
)
var update = flag.Bool("update", false, "update golden files")
func TestGoldenFile(t *testing.T) {
input := "test input"
result := Process(input)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
os.WriteFile(golden, []byte(result), 0644)
}
expected, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if result != string(expected) {
t.Errorf("got:\n%s\nwant:\n%s", result, expected)
}
}
Résumé
- testing.T : tests unitaires, table-driven, subtests
- testify/require : assertions avec arrêt
- testify/mock : mocking d'interfaces
- Fuzzing : tests aléatoires (Go 1.18+)
- Benchmarks : -bench, -benchmem
- Profiling : -cpuprofile, -memprofile, -trace
- Coverage : -cover, -coverprofile
- Integration : build tags, testcontainers
- Golden files : fichiers de référence