MFormations
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