Go-Testmuster
Umfassende Go-Testmuster für das Schreiben zuverlässiger, wartbarer Tests nach der TDD-Methodik.
Wann aktivieren
- Beim Schreiben neuer Go-Funktionen oder -Methoden
- Beim Hinzufügen von Testabdeckung zu vorhandenem Code
- Beim Erstellen von Benchmarks für leistungskritischen Code
- Beim Implementieren von Fuzz-Tests für die Eingabevalidierung
- Beim Befolgen des TDD-Workflows in Go-Projekten
TDD-Workflow für Go
Der RED-GREEN-REFACTOR-Zyklus
ROT → Schreiben Sie zuerst einen fehlschlagenden Test
GRÜN → Schreiben Sie minimalen Code, um den Test zu bestehen
REFACTOR → Verbessern Sie den Code, während die Tests grün bleiben
WIEDERHOLEN → Fahren Sie mit der nächsten Anforderung fort
Schritt-für-Schritt-TDD in Go
// Schritt 1: Die Schnittstelle/Signatur definieren
// calculator.go
package calculator
func Add(a, b int) int {
panic("nicht implementiert") // Platzhalter
}
// Schritt 2: Fehlschlagenden Test schreiben (ROT)
// calculator_test.go
package calculator
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)
}
}
// Schritt 3: Test ausführen – FEHLSCHLAG überprüfen
// $ go test
// --- FAIL: TestAdd (0.00s)
// panic: nicht implementiert
// Schritt 4: Minimalen Code implementieren (GRÜN)
func Add(a, b int) int {
return a + b
}
// Schritt 5: Test ausführen – BESTANDEN überprüfen
// $ go test
// PASS
// Schritt 6: Bei Bedarf umgestalten, überprüfen, ob Tests weiterhin bestanden werden
Tabellengesteuerte Tests
Das Standardmuster für Go-Tests. Ermöglicht umfassende Abdeckung mit minimalem Code.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive Zahlen", 2, 3, 5},
{"negative Zahlen", -1, -2, -3},
{"Nullwerte", 0, 0, 0},
{"gemischte Vorzeichen", -1, 1, 0},
{"große Zahlen", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
Tabellengesteuerte Tests mit Fehlerfällen
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "gültige Konfiguration",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "ungültiges JSON",
input: `{invalid}`,
wantErr: true,
},
{
name: "leere Eingabe",
input: "",
wantErr: true,
},
{
name: „minimale Konfiguration",
input: `{}`,
want: &Config{}, // Konfiguration mit Nullwerten
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig(tt.input)
if tt.wantErr {
if err == nil {
t.Error("erwarteter Fehler, erhalten: nil")
}
return
}
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("erhalten %+v; erwartet %+v", got, tt.want)
}
})
}
}
Subtests und Sub-Benchmarks
Organisation verwandter Tests
func TestUser(t *testing.T) {
// Von allen Subtests gemeinsam genutzte Einrichtung
db := setupTestDB(t)
t.Run("Erstellen", func(t *testing.T) {
user := &User{Name: "Alice"}
err := db.CreateUser(user)
if err != nil {
t.Fatalf("CreateUser fehlgeschlagen: %v", err)
}
if user.ID == "" {
t.Error("erwartet, dass die Benutzer-ID gesetzt ist")
}
})
t.Run("Abrufen", func(t *testing.T) {
user, err := db.GetUser("alice-id")
if err != nil {
t.Fatalf("GetUser fehlgeschlagen: %v", err)
}
if user.Name != "Alice" {
t.Errorf("erhaltener Name %q; erwartet %q", user.Name, "Alice")
}
})
t.Run("Aktualisieren", func(t *testing.T) {
// ...
})
t.Run("Löschen", func(t *testing.T) {
// ...
})
}
Parallele Subtests
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"fall1", "eingabe1"},
{"fall2", "eingabe2"},
{"fall3", "eingabe3"},
}
for _, tt := range tests {
tt := tt // Bereichsvariable erfassen
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Subtests parallel ausführen
result := Process(tt.input)
// Behauptungen...
_ = result
})
}
}
Test-Helfer
Hilfsfunktionen
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // Markiert dies als Hilfsfunktion
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Fehler beim Öffnen der Datenbank: %v", err)
}
// Bereinigung, wenn der Test abgeschlossen ist
t.Cleanup(func() {
db.Close()
})
// Migrationen ausführen
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Fehler beim Erstellen des Schemas: %v", err)
}
return db
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("erhalten %v; erwartet %v", got, want)
}
}
Temporäre Dateien und Verzeichnisse
func TestFileProcessing(t *testing.T) {
// Temporäres Verzeichnis erstellen – automatisch bereinigt
tmpDir := t.TempDir()
// Testdatei erstellen
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("Testinhalt"), 0644)
if err != nil {
t.Fatalf("Fehler beim Erstellen der Testdatei: %v", err)
}
// Test ausführen
result, err := ProcessFile(testFile)
if err != nil {
t.Fatalf("ProcessFile fehlgeschlagen: %v", err)
}
// Behauptung...
_ = result
}
Goldene Dateien
Testen gegen erwartete Ausgabedateien, die in testdata/ gespeichert sind.
var update = flag.Bool("update", false, "goldene Dateien aktualisieren")
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
}{
{"einfach", Template{Name: "test"}},
{"komplex", Template{Name: "test", Items: []string{"a", "b"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
golden := filepath.Join("testdata", tt.name+".golden")
if *update {
// Goldene Datei aktualisieren: go test -update
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatalf("Fehler beim Aktualisieren der goldenen Datei: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("Fehler beim Lesen der goldenen Datei: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("Ausgabe stimmt nicht überein:\nerhalten:\n%s\nerwartet:\n%s", got, want)
}
})
}
}
Mocken mit Interfaces
Interface-basiertes Mocken
// Interface für Abhängigkeiten definieren
type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
// Produktionsimplementierung
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
// Echte Datenbankabfrage
}
// Mock-Implementierung für Tests
type MockUserRepository struct {
GetUserFunc func(id string) (*User, error)
SaveUserFunc func(user *User) error
}
func (m *MockUserRepository) GetUser(id string) (*User, error) {
return m.GetUserFunc(id)
}
func (m *MockUserRepository) SaveUser(user *User) error {
return m.SaveUserFunc(user)
}
// Test mit Mock
func TestUserService(t *testing.T) {
mock := &MockUserRepository{
GetUserFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
service := NewUserService(mock)
user, err := service.GetUserProfile("123")
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if user.Name != "Alice" {
t.Errorf("erhaltener Name %q; erwartet %q", user.Name, "Alice")
}
}
Benchmarks
Einfache Benchmarks
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer() // Einrichtungszeit nicht zählen
for i := 0; i < b.N; i++ {
Process(data)
}
}
// Ausführen: go test -bench=BenchmarkProcess -benchmem
// Ausgabe: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
Benchmark mit verschiedenen Größen
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Eine Kopie erstellen, um das Sortieren bereits sortierter Daten zu vermeiden
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
Benchmarks zur Speicherplatzzuweisung
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"hallo", "welt", "foo", "bar", "baz"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
})
}
Fuzzing (Go 1.18+)
Einfacher Fuzz-Test
func FuzzParseJSON(f *testing.F) {
// Seed-Korpus hinzufügen
f.Add(`{"name": "test"}`)
f.Add(`{"count": 123}`)
f.Add(`[]`)
f.Add(`""`)
f.Fuzz(func(t *testing.T, input string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(input), &result)
if err != nil {
// Für zufällige Eingabe wird ungültiges JSON erwartet
return
}
// Wenn das Parsen erfolgreich war, sollte die erneute Kodierung funktionieren
_, err = json.Marshal(result)
if err != nil {
t.Errorf("Marshal fehlgeschlagen nach erfolgreichem Unmarshal: %v", err)
}
})
}
// Ausführen: go test -fuzz=FuzzParseJSON -fuzztime=30s
Fuzz-Test mit mehreren Eingaben
func FuzzCompare(f *testing.F) {
f.Add("hallo", "welt")
f.Add("", "")
f.Add("abc", "abc")
f.Fuzz(func(t *testing.T, a, b string) {
result := Compare(a, b)
// Eigenschaft: Compare(a, a) sollte immer 0 ergeben
if a == b && result != 0 {
t.Errorf("Compare(%q, %q) = %d; erwartet 0", a, b, result)
}
// Eigenschaft: Compare(a, b) und Compare(b, a) sollten entgegengesetzte Vorzeichen haben
reverse := Compare(b, a)
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
if result != 0 || reverse != 0 {
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inkonsistent",
a, b, result, b, a, reverse)
}
}
})
}
Testabdeckung
Ausführen der Abdeckungsanalyse
# Einfache Abdeckung
go test -cover ./...
# Abdeckungsprofil generieren
go test -coverprofile=coverage.out ./...
# Abdeckung im Browser anzeigen
go tool cover -html=coverage.out
# Abdeckung nach Funktion anzeigen
go tool cover -func=coverage.out
# Abdeckung mit Race-Erkennung
go test -race -coverprofile=coverage.out ./...
Abdeckungsziele
| Code-Typ | Ziel |
|---|---|
| Kritische Geschäftslogik | 100% |
| Öffentliche APIs | 90%+ |
| Allgemeiner Code | 80%+ |
| Generierter Code | Ausgeschlossen |
Generierten Code von der Abdeckung ausschließen
//go:generate mockgen -source=interface.go -destination=mock_interface.go
// Im Abdeckungsprofil mit Build-Tags ausschließen:
// go test -cover -tags=!generate ./...
HTTP-Handler-Tests
func TestHealthHandler(t *testing.T) {
// Anfrage erstellen
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// Handler aufrufen
HealthHandler(w, req)
// Antwort überprüfen
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("erhaltener Status %d; erwartet %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "OK" {
t.Errorf("erhaltener Inhalt %q; erwartet %q", body, "OK")
}
}
func TestAPIHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "Benutzer abrufen",
method: http.MethodGet,
path: "/users/123",
wantStatus: http.StatusOK,
wantBody: `{"id":"123","name":"Alice"}`,
},
{
name: "nicht gefunden",
method: http.MethodGet,
path: "/users/999",
wantStatus: http.StatusNotFound,
},
{
name: "Benutzer erstellen",
method: http.MethodPost,
path: "/users",
body: `{"name":"Bob"}`,
wantStatus: http.StatusCreated,
},
}
handler := NewAPIHandler()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ...
})
}
}