Go-eenheidstesten: structuur en beste praktijken

Go-testen van basis tot geavanceerde patronen

Inhoud

Go’s built-in testing package biedt een krachtig, minimalistisch kader voor het schrijven van eenheidstests zonder externe afhankelijkheden. Hier zijn de basisprincipes van testen, projectstructuur en geavanceerde patronen om betrouwbare Go-toepassingen te bouwen.

Go Unit Testing is awesome

Waarom testen belangrijk is in Go

Go’s filosofie benadrukt eenvoud en betrouwbaarheid. Het standaardbibliotheek bevat het testing-pakket, waardoor eenheidstesten een eerste klasse burger zijn in de Go-ecosysteem. Go-code die goed getest is verbetert onderhoudbaarheid, vangt fouten vroeg op en biedt documentatie via voorbeelden. Als je nieuw bent in Go, bekijk dan onze Go Cheat Sheet voor een snelle verwijzing naar de taalbasis.

Belangrijke voordelen van Go-testen:

  • Ingebouwde ondersteuning: Geen externe frameworks vereist
  • Snelle uitvoering: Concurrente testuitvoering als standaard
  • Eenvoudige syntaxis: Minimale boilerplate-code
  • Rijke tooling: Declaraties over dekking, benchmarks en profielering
  • CI/CD-vriendelijk: Eenvoudige integratie met geautomatiseerde pijplijnen

Projectstructuur voor Go-tests

Go-tests bevinden zich naast je productiecode met een duidelijke naamgevingsconventie:

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

Belangrijke conventies:

  • Testbestanden eindigen met _test.go
  • Tests bevinden zich in hetzelfde pakket als de code (of gebruiken de _test-suffix voor zwartkisttesten)
  • Elke bronbestand kan een overeenkomstig testbestand hebben

Pakkettestenbenaderingen

Witkisttesten (zelfde pakket):

package calculator

import "testing"
// Kan ongeëxporteerde functies en variabelen toegankelijk maken

Zwartkisttesten (extern pakket):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Kan alleen geëxporteerde functies toegankelijk maken (aangeraden voor openbare API's)

Basisstructuur van een test

Elke testfunctie volgt dit patroon:

package calculator

import "testing"

// Testfunctie moet beginnen met "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)
    }
}

Testing.T methoden:

  • t.Error() / t.Errorf(): Markeer test als mislukt maar voortzetten
  • t.Fatal() / t.Fatalf(): Markeer test als mislukt en stop direct
  • t.Log() / t.Logf(): Loguitvoer (alleen zichtbaar met -v-vlag)
  • t.Skip() / t.Skipf(): Overslaan van de test
  • t.Parallel(): Voer test parallel uit met andere parallelle tests

Tabelgeleide tests: De Go-achtige manier

Tabelgeleide tests zijn de idiomatische Go-achtige aanpak voor het testen van meerdere scenario’s. Met Go generics, kun je ook typesafe testhelpers maken die werken over verschillende datatypes:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"additie", 2, 3, "+", 5, false},
        {"subtraheren", 5, 3, "-", 2, false},
        {"vermenigvuldigen", 4, 3, "*", 12, false},
        {"delen", 10, 2, "/", 5, false},
        {"delen door nul", 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)
            }
        })
    }
}

Voordelen:

  • Een enkele testfunctie voor meerdere scenario’s
  • Eenvoudig toevoegen van nieuwe testgevallen
  • Duidelijke documentatie van verwachte gedrag
  • Betere testorganisatie en onderhoudbaarheid

Tests uitvoeren

Basiscommando’s

# Tests uitvoeren in huidige map
go test

# Tests uitvoeren met uitgebreide uitvoer
go test -v

# Tests uitvoeren in alle submappen
go test ./...

# Specifieke test uitvoeren
go test -run TestAdd

# Tests uitvoeren met patroon
go test -run TestCalculate/additie

# Tests uitvoeren parallel (standaard is GOMAXPROCS)
go test -parallel 4

# Tests uitvoeren met timeout
go test -timeout 30s

Testdekking

# Tests uitvoeren met dekking
go test -cover

# Dekking profiel genereren
go test -coverprofile=coverage.out

# Dekking in browser bekijken
go tool cover -html=coverage.out

# Dekking per functie tonen
go tool cover -func=coverage.out

# Dekkingmodus instellen (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Nuttige vlaggen

  • -short: Voer tests uit gemarkeerd met if testing.Short() checks
  • -race: Race detector inschakelen (vindt concurrentieproblemen)
  • -cpu: GOMAXPROCS-waarden opgeven
  • -count n: Elke test n keer uitvoeren
  • -failfast: Stop bij eerste testmislukking

Testhelpers en opzet/afsluiten

Helperfuncties

Markeer helperfuncties met t.Helper() om foutmeldingen te verbeteren:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Deze regel wordt als de aanroeper gemeld
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Foutregel wijst hierheen
}

Opzet en afsluiten

func TestMain(m *testing.M) {
    // Opzetcode hier
    setup()
    
    // Tests uitvoeren
    code := m.Run()
    
    // Afsluitcode hier
    teardown()
    
    os.Exit(code)
}

Testfixture’s

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("testgeval opzetten")
    return func(t *testing.T) {
        t.Log("testgeval afsluiten")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Testcode hier
}

Mocken en afhankelijkheidinjectie

Interfacegebaseerd mocken

Wanneer je code test die interactie heeft met databases, maakt het gebruik van interfaces het gemakkelijk om mockimplementaties te maken. Als je werkt met PostgreSQL in Go, zie dan onze vergelijking van Go ORMs voor het kiezen van het juiste databaselib met goede testbaarheid.

// Productiecode
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
}

// Testcode
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("gebruiker niet gevonden")
}

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("onverwachte fout: %v", err)
    }
    if name != "Alice" {
        t.Errorf("kreeg %s, wilde Alice", name)
    }
}

Populaire testbibliotheken

Testify

De meest populaire Go-testbibliotheek voor beweringen en 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, "ze moeten gelijk zijn")
    assert.NotNil(t, result)
}

// Mockvoorbeeld
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)
}

Andere tools

  • gomock: Google’s mockframework met codegeneratie
  • httptest: Standaardbibliotheek voor het testen van HTTP-handlers
  • testcontainers-go: Integratietesten met Docker-containers
  • ginkgo/gomega: BDD-stijl testframework

Wanneer je integraties test met externe diensten zoals AI-modellen, zul je die afhankelijkheden moeten mocken of stubben. Bijvoorbeeld, als je Ollama in Go gebruikt, overweeg dan het maken van interface wrappers om je code beter te testen.

Benchmarktests

Go biedt ingebouwde ondersteuning voor benchmarks:

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

// Benchmarks uitvoeren
// go test -bench=. -benchmem

De uitvoer toont iteraties per seconde en geheugenallocaties.

Beste praktijken

  1. Schrijf tabelgeleide tests: Gebruik het slice van structs patroon voor meerdere testgevallen
  2. Gebruik t.Run voor subtests: Betere organisatie en kan subtests selectief uitvoeren
  3. Test eerst geëxporteerde functies: Focus op het gedrag van de openbare API
  4. Houd tests eenvoudig: Elke test moet één ding controleren
  5. Gebruik betekenisvolle testnamen: Beschrijf wat getest wordt en verwachte uitkomst
  6. Test geen implementatiedetails: Test gedrag, niet interne details
  7. Gebruik interfaces voor afhankelijkheden: Maakt mocken gemakkelijker
  8. Streef naar hoge dekking, maar kwaliteit boven kwantiteit: 100% dekking betekent niet foutvrij
  9. Voer tests uit met -race vlag: Vang concurrentieproblemen vroeg op
  10. Gebruik TestMain voor duurzame opzet: Vermijd herhalen van opzet in elke test

Voorbeeld: Volledige testsuite

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("naam kan niet leeg zijn")
    }
    if u.Email == "" {
        return errors.New("email kan niet leeg zijn")
    }
    return nil
}

// Testbestand: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "geldige gebruiker",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "lege naam",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "naam kan niet leeg zijn",
        },
        {
            name:    "lege email",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "email kan niet leeg zijn",
        },
    }

    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)
            }
        })
    }
}

Conclusie

Go’s testframework biedt alles wat nodig is voor uitgebreide eenheidstesten met minimale opzet. Door Go-idiomen zoals tabelgeleide tests te volgen, interfaces te gebruiken voor mocken en ingebouwde tools te benutten, kun je onderhoudbare, betrouwbare testsuites maken die groeien met je codebasis.

Deze testpraktijken zijn van toepassing op alle soorten Go-toepassingen, van webdiensten tot CLI-applicaties gebouwd met Cobra & Viper. Het testen van command-line tools vereist vergelijkbare patronen met extra aandacht voor het testen van invoer/uitvoer en vlaggenverwerking.

Begin met eenvoudige tests, voeg geleidelijk dekking toe en onthoud dat testen een investering is in codekwaliteit en ontwikkelaarsvertrouwen. De nadruk van de Go-community op testen maakt het makkelijker om projecten op lange termijn te onderhouden en effectief samen te werken met teamleden.