Go Unit Testing: Struktur & Best Practices

Testen von Grundlagen bis zu fortgeschrittenen Mustern

Inhaltsverzeichnis

Go’s eingebaute Testpaket bietet einen leistungsstarken, minimalistischen Rahmen für das Schreiben von Einheitstests ohne externe Abhängigkeiten. Hier sind die Testgrundlagen, die Projektstruktur und fortgeschrittene Muster, um zuverlässige Go-Anwendungen zu erstellen.

Go Unit Testing ist großartig

Warum Testing in Go wichtig ist

Die Philosophie von Go betont Einfachheit und Zuverlässigkeit. Die Standardbibliothek enthält das testing-Paket, wodurch Einheitstests zu einer erstklassigen Funktion im Go-Ökosystem werden. Gut getesteter Go-Code verbessert die Wartbarkeit, erkennt Fehler frühzeitig und bietet Dokumentation durch Beispiele. Wenn Sie neu in Go sind, werfen Sie einen Blick auf unseren Go Cheat Sheet für einen schnellen Überblick über die Grundlagen der Sprache.

Wichtige Vorteile von Go-Tests:

  • Eingebaute Unterstützung: Keine externen Frameworks erforderlich
  • Schnelle Ausführung: Standardmäßig parallele Testausführung
  • Einfache Syntax: Minimale Boilerplate-Code
  • Umfassende Tools: Abdeckungsberichte, Benchmarks und Profiling
  • CI/CD-freundlich: Einfache Integration in automatisierte Pipelines

Projektstruktur für Go-Tests

Go-Tests befinden sich neben dem Produktionscode mit einer klaren Namenskonvention:

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

Wichtige Konventionen:

  • Testdateien enden mit _test.go
  • Tests befinden sich im gleichen Paket wie der Code (oder verwenden den _test-Suffix für Black-Box-Tests)
  • Jede Quelldatei kann eine entsprechende Testdatei haben

Paket-Testansätze

White-Box-Testing (gleiches Paket):

package calculator

import "testing"
// Kann nicht exportierte Funktionen und Variablen zugreifen

Black-Box-Testing (externes Paket):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Kann nur exportierte Funktionen zugreifen (empfohlen für öffentliche APIs)

Grundlegende Teststruktur

Jede Testfunktion folgt diesem Muster:

package calculator

import "testing"

// Testfunktion muss mit "Test" beginnen
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(): Markiere Test als fehlgeschlagen, aber setze fort
  • t.Fatal() / t.Fatalf(): Markiere Test als fehlgeschlagen und stoppe sofort
  • t.Log() / t.Logf(): Protokollausgabe (nur mit -v-Flag angezeigt)
  • t.Skip() / t.Skipf(): Überspringe den Test
  • t.Parallel(): Führe Test parallel mit anderen parallelen Tests aus

Tabellengetriebene Tests: Der Go-Weg

Tabellengetriebene Tests sind der idiomatische Go-Ansatz zum Testen mehrerer Szenarien. Mit Go-Generics können Sie auch typsichere Test-Helper erstellen, die mit verschiedenen Datentypen funktionieren:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"Addition", 2, 3, "+", 5, false},
        {"Subtraktion", 5, 3, "-", 2, false},
        {"Multiplikation", 4, 3, "*", 12, false},
        {"Division", 10, 2, "/", 5, false},
        {"Division durch Null", 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)
            }
        })
    }
}

Vorteile:

  • Eine Testfunktion für mehrere Szenarien
  • Einfache Hinzufügung neuer Testfälle
  • Klare Dokumentation des erwarteten Verhaltens
  • Bessere Testorganisation und -wartbarkeit

Tests ausführen

Grundlegende Befehle

# Führe Tests im aktuellen Verzeichnis aus
go test

# Führe Tests mit detaillierter Ausgabe aus
go test -v

# Führe Tests in allen Unterverzeichnissen aus
go test ./...

# Führe einen bestimmten Test aus
go test -run TestAdd

# Führe Tests aus, die einem Muster entsprechen
go test -run TestCalculate/addition

# Führe Tests parallel aus (Standard ist GOMAXPROCS)
go test -parallel 4

# Führe Tests mit Timeout aus
go test -timeout 30s

Testabdeckung

# Führe Tests mit Abdeckung aus
go test -cover

# Generiere Abdeckungsprofil
go test -coverprofile=coverage.out

# Zeige Abdeckung im Browser
go tool cover -html=coverage.out

# Zeige Abdeckung nach Funktion
go tool cover -func=coverage.out

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

Nützliche Flags

  • -short: Führe Tests aus, die mit if testing.Short()-Prüfungen markiert sind
  • -race: Aktiviere Race-Detector (findet Probleme mit gleichzeitigem Zugriff)
  • -cpu: Geben Sie GOMAXPROCS-Werte an
  • -count n: Führe jeden Test n Mal aus
  • -failfast: Stoppen Sie beim ersten Testfehler

Test-Helper und Setup/Teardown

Helper-Funktionen

Markieren Sie Helper-Funktionen mit t.Helper(), um die Fehlerberichterstattung zu verbessern:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Diese Zeile wird als Aufrufer gemeldet
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Fehlerzeile zeigt hier
}

Setup und Teardown

func TestMain(m *testing.M) {
    // Setup-Code hier
    setup()

    // Führe Tests aus
    code := m.Run()

    // Teardown-Code hier
    teardown()

    os.Exit(code)
}

Test-Fixtures

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("setup test case")
    return func(t *testing.T) {
        t.Log("teardown test case")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)

    // Test-Code hier
}

Mocking und Dependency Injection

Interface-basiertes Mocking

Wenn Sie Code testen, der mit Datenbanken interagiert, macht es das Erstellen von Mock-Implementierungen einfach, wenn Sie Schnittstellen verwenden. Wenn Sie mit PostgreSQL in Go arbeiten, werfen Sie einen Blick auf unseren Vergleich von Go-ORMs zur Auswahl der richtigen Datenbankbibliothek mit guter Testbarkeit.

// Produktionscode
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("user not found")
}

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("unexpected error: %v", err)
    }
    if name != "Alice" {
        t.Errorf("got %s, want Alice", name)
    }
}

Beliebte Testbibliotheken

Testify

Die beliebteste Go-Testbibliothek für Assertions und 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, "they should be equal")
    assert.NotNil(t, result)
}

// Mock-Beispiel
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: Googles Mocking-Framework mit Codegenerierung
  • httptest: Standardbibliothek zum Testen von HTTP-Handlern
  • testcontainers-go: Integrationstests mit Docker-Containern
  • ginkgo/gomega: BDD-ähnlicher Testframework

Wenn Sie Integrationen mit externen Diensten wie KI-Modellen testen, müssen Sie diese Abhängigkeiten mocken oder stubben. Wenn Sie beispielsweise Ollama in Go verwenden, sollten Sie Schnittstellenumhüllungen erstellen, um Ihren Code testbarer zu machen.

Benchmark-Tests

Go bietet eingebaute Unterstützung für Benchmarks:

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

// Führe Benchmarks aus
// go test -bench=. -benchmem

Die Ausgabe zeigt Iterationen pro Sekunde und Speicherzuweisungen.

Best Practices

  1. Schreiben Sie tabellengetriebene Tests: Verwenden Sie das Slice-of-Structs-Muster für mehrere Testfälle
  2. Verwenden Sie t.Run für Untertests: Bessere Organisation und selektive Ausführung von Untertests
  3. Testen Sie exportierte Funktionen zuerst: Konzentrieren Sie sich auf das Verhalten der öffentlichen API
  4. Halten Sie Tests einfach: Jeder Test sollte eine Sache überprüfen
  5. Verwenden Sie aussagekräftige Testnamen: Beschreiben Sie, was getestet wird und welches Ergebnis erwartet wird
  6. Testen Sie keine Implementierungsdetails: Testen Sie Verhalten, nicht Interna
  7. Verwenden Sie Schnittstellen für Abhängigkeiten: Erleichtert das Mocking
  8. Streben Sie hohe Abdeckung an, aber Qualität vor Quantität: 100% Abdeckung bedeutet nicht fehlerfrei
  9. Führen Sie Tests mit dem -race-Flag aus: Erkennen Sie Concurrency-Probleme frühzeitig
  10. Verwenden Sie TestMain für teuren Setup: Vermeiden Sie wiederholten Setup in jedem Test

Beispiel: Vollständiger 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("name cannot be empty")
    }
    if u.Email == "" {
        return errors.New("email cannot be empty")
    }
    return nil
}

// Testdatei: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "gültiger Benutzer",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "leerer Name",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "name cannot be empty",
        },
        {
            name:    "leere E-Mail",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "email cannot be empty",
        },
    }

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

Fazit

Das Testframework von Go bietet alles, was für umfassende Unit-Tests mit minimaler Einrichtung benötigt wird. Durch die Verwendung von Go-Idiomen wie tabellengetriebenen Tests, die Nutzung von Schnittstellen zum Mocking und die Ausnutzung eingebauter Tools können Sie wartbare, zuverlässige Testsuiten erstellen, die mit Ihrer Codebasis wachsen.

Diese Testpraktiken gelten für alle Arten von Go-Anwendungen, von Webdiensten bis hin zu CLI-Anwendungen, die mit Cobra & Viper erstellt wurden. Das Testen von Command-Line-Tools erfordert ähnliche Muster mit zusätzlichem Fokus auf das Testen von Eingabe/Ausgabe und Flag-Analyse.

Beginnen Sie mit einfachen Tests, erweitern Sie schrittweise die Abdeckung und denken Sie daran, dass Tests eine Investition in die Codequalität und das Vertrauen der Entwickler sind. Der Fokus der Go-Community auf Tests macht es einfacher, Projekte langfristig zu warten und effektiv mit Teammitgliedern zusammenzuarbeiten.