Goede tests geven je vertrouwen om snel te refactoren en features toe te voegen zonder dat er dingen stuk gaan. Gelukkig maakt Go testing bijzonder laagdrempelig: er zit een compleet testing pakket in de standaardbibliotheek, compleet met unit tests, benchmarks en coverage-tooling. Geen extra dependencies, geen complexe configuratie.
In dit artikel leer je hoe je testing in Go toepast. Van je eerste _test.go bestand tot tabletests, subtests, mocks en coverage-rapportages. Met praktische voorbeelden die je direct in je eigen project kunt gebruiken.
Waarom testing in Go zo fijn werkt
Go behandelt tests als eersteklas burgers. Het testing pakket zit ingebouwd, go test is onderdeel van de toolchain, en de conventies zijn door het hele ecosysteem hetzelfde. Of je nu in een klein CLI-tooltje werkt of aan een grote microservice bouwt, je tests zien er vrijwel identiek uit.
Dat past bij de filosofie die we eerder behandelden in waarom Go populair is voor backends: eenvoud boven alles. Geen BDD-DSL, geen "magic", gewoon functies die je kunt lezen en debuggen.
Je eerste test schrijven
Testfiles in Go eindigen altijd op _test.go en staan naast de code die ze testen. Stel je hebt een bestand math.go met een functie Add.
// math.go
package mathx
func Add(a, b int) int {
return a + b
}
Dan maak je math_test.go ernaast.
// math_test.go
package mathx
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)
}
}
Draai de tests met go test ./... vanuit de root van je module. De test detectie werkt op basis van conventies: de functie moet beginnen met Test, gevolgd door een hoofdletter, en één argument *testing.T accepteren.
t.Error versus t.Fatal
Gebruik t.Errorf als je meerdere assertions wil laten doorlopen, en t.Fatalf als verdere checks geen zin meer hebben. Bijvoorbeeld na een onverwachte error waarbij de rest van de test crashed op een nil-pointer.
Tabletests: het Go-idioom voor test variaties
In plaats van per scenario een aparte testfunctie te schrijven, groepeer je cases in een slice. Dit heet een tabletest en is het dominante patroon in de Go community.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positieve getallen", 2, 3, 5},
{"met nul", 0, 5, 5},
{"negatief", -2, 3, 1},
{"beide negatief", -2, -3, -5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Met t.Run maak je subtests. Die verschijnen apart in de output en kun je individueel draaien via go test -run TestAdd/met_nul. Een falende subtest stopt de andere cases niet, wat debugging een stuk prettiger maakt.
Errors testen
Functions in Go retourneren vaak een error. Zoals we in functions en error handling zagen is dat het idiomatische patroon. In tests wil je zowel de happy path als de error path dekken.
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normale deling", 10, 2, 5, false},
{"delen door nul", 10, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Fatalf("Divide() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("Divide() = %v, want %v", got, tt.want)
}
})
}
}
Voor specifieke error-checks gebruik je errors.Is of errors.As. Dat is robuuster dan string-vergelijkingen, want die breken zodra iemand de tekst van een error-message aanpast.
HTTP handlers testen
Het net/http/httptest pakket is goud waard als je een HTTP server bouwt. Je kunt handlers testen zonder daadwerkelijk een poort te openen. Dit bouwt direct voort op wat we behandelden in HTTP servers bouwen in Go.
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("status = %d; want %d", res.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(res.Body)
if string(body) != `{"status":"ok"}` {
t.Errorf("body = %s; want ok", body)
}
}
Voor end-to-end tests over meerdere handlers heen kun je httptest.NewServer gebruiken. Die start een echte server op een willekeurige poort en geeft je de URL om tegen te queryen.
Test fixtures en testdata
Go kent een bijzondere conventie: de folder testdata wordt genegeerd door de build-tool. Daar zet je JSON-files, SQL-dumps of andere vaste input voor je tests.
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile("testdata/config.json")
if err != nil {
t.Fatalf("kan testdata niet lezen: %v", err)
}
cfg, err := ParseConfig(data)
if err != nil {
t.Fatalf("ParseConfig error: %v", err)
}
if cfg.Port != 8080 {
t.Errorf("Port = %d; want 8080", cfg.Port)
}
}
Gebruik dit patroon ook bij het testen van JSON handling: bewaar realistische API-responses in testdata/ zodat je tests blijven werken zonder internetverbinding.
Setup en teardown met TestMain
Soms heb je globale setup nodig, bijvoorbeeld een testdatabase starten. Daarvoor bestaat TestMain.
func TestMain(m *testing.M) {
// setup
db := startTestDatabase()
code := m.Run()
// teardown
db.Close()
os.Exit(code)
}
Per test kun je ook t.Cleanup gebruiken om resources op te ruimen, ongeacht of de test slaagt of faalt. Dat is vaak overzichtelijker dan defer omdat de cleanup na alle subtests loopt.
Mocks en interfaces
Go heeft geen ingebouwd mock framework nodig. Door het interface systeem schrijf je zelf makkelijk een stub.
type UserRepo interface {
FindByID(id int) (*User, error)
}
type fakeRepo struct {
users map[int]*User
}
func (f *fakeRepo) FindByID(id int) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, errors.New("niet gevonden")
}
return u, nil
}
func TestUserService(t *testing.T) {
repo := &fakeRepo{users: map[int]*User{
1: {ID: 1, Name: "Sander"},
}}
svc := NewUserService(repo)
u, err := svc.Get(1)
if err != nil {
t.Fatalf("onverwachte error: %v", err)
}
if u.Name != "Sander" {
t.Errorf("Name = %s; want Sander", u.Name)
}
}
Tools als mockgen kunnen deze stubs automatisch genereren, maar voor simpele gevallen is handmatig schrijven vaak sneller en duidelijker.
Coverage meten
Go heeft coverage ingebouwd. Simpel en effectief.
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
De laatste opent een HTML-rapport in je browser waarin gedekte regels groen zijn en niet-geteste regels rood. Jaag niet blind op 100% coverage: richt je op de paden die er toe doen, zoals foutafhandeling en edge cases.
Benchmarks en parallelisme
Het testing pakket ondersteunt ook benchmarks. Die gebruik je om performance te meten en regressies te detecteren.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
Draai met go test -bench=.. Wil je tests parallel laten lopen? Voeg t.Parallel() toe aan het begin van een testfunctie. Dit kan flinke tijdswinst opleveren bij tests met I/O, zoals integratietests met een database, zie database toegang in Go voor voorbeelden van zulke setups.
Tips voor onderhoudbare tests
- Test gedrag, geen implementatie. Als een test breekt bij elke refactor, test je te diep.
- Houd tests snel. Unit tests onder een seconde, integratietests gescheiden via build tags.
- Duidelijke faalboodschappen. Log zowel
gotalswantzodat de oorzaak direct helder is. - Geen gedeelde mutable state tussen tests. Dat veroorzaakt flaky tests die soms wel en soms niet slagen.
- Run tests in CI. Combineer met
go veten linters zoals golangci-lint voor volledige dekking.
Voor diepere achtergrond over test design raad ik de officiële Go testing documentatie aan.
Veelgestelde vragen
Wat is het testing pakket in Go?
Het testing pakket is Go's ingebouwde library voor het schrijven van unit tests, benchmarks en examples. Je hebt geen extra framework nodig, alles werkt direct via het go test commando.
Wat zijn tabletests in Go?
Tabletests zijn een patroon waarbij je meerdere testcases definieert in een slice van structs en die in een loop uitvoert. Zo test je efficiënt veel scenario's met minimale code en duidelijke foutmeldingen per case.
Hoe meet ik test coverage in Go?
Gebruik go test -cover voor een percentage, of go test -coverprofile=coverage.out gevolgd door go tool cover -html=coverage.out voor een visueel rapport per bestand. Richt je op kritieke paden, niet op een perfecte score.
Wat is het verschil tussen t.Error en t.Fatal?
t.Error logt een fout maar laat de test doorlopen, t.Fatal stopt de test direct. Gebruik t.Fatal als verdere assertions geen zin hebben, bijvoorbeeld na een onverwachte error waarop de rest van de test crasht.
Moet ik externe testframeworks gebruiken in Go?
Meestal niet. De standaardbibliotheek dekt het grootste deel af. Libraries als testify kunnen handig zijn voor assertions of mocks, maar veel Go teams blijven bewust bij de standaard voor eenvoud en minder dependencies.