Tabellengesteuerte Tests in Go

1. Überblick

Wenn eine Funktion viele Kontrollflüsse hat, möchtest du viele verschiedene Input-/Output-Paare testen. Wenn du jeden Fall als separate Testfunktion schreibst, wirst du viel Code wiederholen und die Wartung wird schwierig.

Table-driven Tests sind die häufigste Testweise in der Go-Community. Du beschreibst die Testfälle als eine „Tabelle” von Daten und verwendest dann eine einzige Schleife, um alle auszuführen.

Nach der Lektüre dieses Artikels wirst du wissen, wie man Table-driven Tests mit Subtests schreibt, Fehler richtig überprüfst, Tests parallel ausführst und komplexe Structs vergleichst. Du wirst auch wissen, wann man diese Technik nicht verwenden sollte.

2. Funktion zum Testen

Wir verwenden eine Funktion, die den Betrag nach Anwendung eines Rabattcodes auf eine Bestellung berechnet. Die Funktion hat genug Verzweigungen zur Veranschaulichung:

// pricing/discount.go
package pricing

import (
	"errors"
	"strings"
)

var (
	ErrNegativeTotal = errors.New("Gesamtbetrag darf nicht negativ sein")
	ErrInvalidCode   = errors.New("Rabattcode ist ungültig")
)

// ApplyDiscount gibt den Betrag (in Dong) nach Anwendung eines Rabattcodes zurück.
func ApplyDiscount(total int, code string) (int, error) {
	if total < 0 {
		return 0, ErrNegativeTotal
	}

	switch strings.ToUpper(strings.TrimSpace(code)) {
	case "":
		return total, nil
	case "SALE10":
		return total * 90 / 100, nil
	case "GIAM50K":
		if total < 50000 {
			return 0, nil
		}
		return total - 50000, nil
	default:
		return 0, ErrInvalidCode
	}
}

Die Funktion hat fünf Verhaltensweisen zu testen: negativer Gesamtbetrag, kein Code, Code SALE10, Code GIAM50K (zwei Branches) und ungültiger Code. Außerdem wird der Code durch TrimSpace und ToUpper normalisiert.

3. Grundstruktur eines Table-driven Tests

3.1. Erste Version

Ein Table-driven Test besteht aus drei Teilen: Deklaration der Tabelle, Iteration über die Tabelle und Vergleich der Ergebnisse.

// pricing/discount_test.go
package pricing

import "testing"

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "kein Code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 Kleinbuchstaben und Leerzeichen", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k kein negativer Betrag", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		got, err := ApplyDiscount(tc.total, tc.code)
		if err != nil {
			t.Fatalf("%s: unerwarteter Fehler: %v", tc.name, err)
		}
		if got != tc.want {
			t.Errorf("%s: ApplyDiscount(%d, %q) = %d, soll %d sein",
				tc.name, tc.total, tc.code, got, tc.want)
		}
	}
}

Erklärung:

  • tests ist ein Slice von anonymem Struct. Jedes Element ist ein Testfall mit Input (total, code) und erwartetem Output (want).
  • Das Feld name hilft dir, zu erkennen, welcher Fall fehlgeschlagen ist.
  • Die for-Schleife ruft die Funktion auf und vergleicht das Ergebnis. Fehlermeldungen nach dem Muster F(input) = got, want X sind eine vertraute Konvention in Go.

Um einen neuen Fall hinzuzufügen, musst du nur eine Zeile zur Tabelle hinzufügen. Das ist der größte Vorteil dieser Technik.

3.2. Probleme mit der ersten Version

Der obige Code läuft, hat aber zwei Nachteile:

  1. t.Fatalf beendet den gesamten Test. Ein fehlgeschlagener Fall führt dazu, dass nachfolgende Fälle nicht ausgeführt werden.
  2. Du kannst einen einzelnen Fall beim Debuggen nicht allein ausführen.

Subtests lösen beide Probleme.

4. Subtests mit t.Run verwenden

t.Run erstellt einen Subtest für jeden Fall. Jeder Subtest hat sein eigenes *testing.T, daher beendet t.Fatal nur den aktuellen Fall.

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "kein Code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 Kleinbuchstaben und Leerzeichen", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k kein negativer Betrag", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("unerwarteter Fehler: %v", err)
			}
			if got != tc.want {
				t.Errorf("ApplyDiscount(%d, %q) = %d, soll %d sein",
					tc.total, tc.code, got, tc.want)
			}
		})
	}
}

Du musst tc.name nicht mehr in die Fehlermeldung drucken, da Go den Subtestnamen in der Ausgabe angehängt hat. Führe mit -v aus:

go test ./pricing -v
=== RUN   TestApplyDiscount
=== RUN   TestApplyDiscount/kein_Code
=== RUN   TestApplyDiscount/sale10
=== RUN   TestApplyDiscount/sale10_Kleinbuchstaben_und_Leerzeichen
=== RUN   TestApplyDiscount/giam50k_normal
=== RUN   TestApplyDiscount/giam50k_kein_negativer_Betrag
--- PASS: TestApplyDiscount (0.00s)
    --- PASS: TestApplyDiscount/kein_Code (0.00s)
    --- PASS: TestApplyDiscount/sale10 (0.00s)
    --- PASS: TestApplyDiscount/sale10_Kleinbuchstaben_und_Leerzeichen (0.00s)
    --- PASS: TestApplyDiscount/giam50k_normal (0.00s)
    --- PASS: TestApplyDiscount/giam50k_kein_negativer_Betrag (0.00s)
PASS

Du siehst, dass Leerzeichen im Namen durch _ ersetzt wurden. Um einen einzelnen Fall auszuführen, übergib ein Regex an -run:

go test ./pricing -run 'TestApplyDiscount/giam50k' -v

Dieser Befehl führt nur die zwei Fälle aus, deren Namen mit giam50k beginnen. Wenn ein Fall fehlschlägt, sieht die Ausgabe wie folgt aus:

--- FAIL: TestApplyDiscount (0.00s)
    --- FAIL: TestApplyDiscount/sale10 (0.00s)
        discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, soll 85000 sein

Du weißt sofort, welcher Fall fehlgeschlagen ist, auf welcher Zeile und mit welcher Eingabe.

Hinweis: Gib den Fällen kurze, eindeutige Namen, die sich leicht auf dem Terminal eingeben lassen. Wenn zwei Fälle den gleichen Namen haben, fügt Go automatisch das Suffix #01 hinzu, was es schwieriger macht, einen einzelnen Fall auszuführen.

5. Fehlerüberprüfung in der Test-Tabelle

Fälle, die Fehler zurückgeben, sollten auch in der gleichen Tabelle stehen. Du fügest ein Feld wantErr vom Typ error hinzu und vergleichst mit errors.Is:

package pricing

import (
	"errors"
	"testing"
)

func TestApplyDiscount_Errors(t *testing.T) {
	tests := []struct {
		name    string
		total   int
		code    string
		want    int
		wantErr error
	}{
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "negativer Gesamtbetrag", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
		{name: "Code existiert nicht", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)

			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, soll %v sein", err, tc.wantErr)
			}
			if tc.wantErr != nil {
				return // Fehler überprüft, kein Rückgabewert erforderlich
			}
			if got != tc.want {
				t.Errorf("erhalten %d, soll %d sein", got, tc.want)
			}
		})
	}
}

Erklärung:

  • Bei erfolgreichen Fällen hat wantErr den Nullwert nil. Dann gibt errors.Is(nil, nil) true zurück.
  • Wenn die Funktion einen Fehler zurückgibt, während du das nicht erwartest, gibt errors.Is(err, nil) false zurück und der Test schlägt sofort fehl.
  • errors.Is funktioniert auch, wenn der Fehler mit fmt.Errorf("...: %w", err) verpackt ist. Der Vergleich err == tc.wantErr funktioniert nicht.

Viele Codebases verwenden wantErr bool der Einfachheit halber. Das sagt dir nur, ob es einen Fehler gibt, nicht welcher Fehler. Du solltest den Vergleich mit Sentinel-Fehlern bevorzugen, wenn das Package sie definiert hat.

6. Parallele Ausführung mit t.Parallel

Wenn Testfälle langsam sind (I/O-Aufrufe, schwere Verarbeitung), kannst du die Subtests parallel ausführen lassen:

func TestApplyDiscount_Parallel(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "kein Code", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("unerwarteter Fehler: %v", err)
			}
			if got != tc.want {
				t.Errorf("erhalten %d, soll %d sein", got, tc.want)
			}
		})
	}
}

t.Parallel() teilt dem Test-Runner mit, dass dieser Subtest zusammen mit anderen Subtests parallel ausgeführt werden kann. Die übergeordnete Funktion pausiert die Subtests, führt die Schleife fertig aus und lässt dann alle Subtests gleichzeitig ausführen.

Ab Go 1.22 erstellt jede for-Schleife eine neue Variable tc, sodass die Closure immer den richtigen Wert erfasst. Mit Go 1.21 und früher musst du die Variable kopieren:

for _, tc := range tests {
	tc := tc // erforderlich für Go < 1.22
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

Wenn dir die Zeile tc := tc bei älteren Versionen fehlt, verwenden alle Subtests den letzten Fall. Der Test wird trotzdem „bestanden”, aber nur ein Fall wird eigentlich überprüft. Das ist ein sehr schwer zu erkennender Fehler. Überprüfe die go-Zeile in deiner go.mod.

Hinweis: Verwende t.Parallel() nur, wenn die Fälle keinen gemeinsamen veränderbaren Zustand teilen, wie globale Variablen, gemeinsam genutzte temporäre Dateien oder eine gemeinsame Datenbanktabelle. Bei reinen Funktionen wie ApplyDiscount ist der Vorteil nahezu null. Füge es nicht aus Gewohnheit hinzu.

7. Komplexe Structs mit go-cmp vergleichen

Wenn eine Funktion einen verschachtelten Struct zurückgibt, kann der Operator != nicht für Structs mit Slices oder Maps verwendet werden. reflect.DeepEqual gibt nur true/false zurück und sagt nicht, was unterschiedlich ist. Die Bibliothek go-cmp löst dies. Du fügst sie zu deinem Modul hinzu:

go get github.com/google/go-cmp/cmp
// go.mod (Auszug)
require github.com/google/go-cmp v0.6.0

Angenommen, du hast eine Funktion, die eine Rechnung erstellt:

// pricing/invoice.go
package pricing

type Invoice struct {
	Customer string
	Items    []string
	Total    int
}

func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
	final, err := ApplyDiscount(total, code)
	if err != nil {
		return Invoice{}, err
	}
	return Invoice{Customer: customer, Items: items, Total: final}, nil
}

Test mit cmp.Diff:

package pricing

import (
	"testing"

	"github.com/google/go-cmp/cmp"
)

func TestBuildInvoice(t *testing.T) {
	tests := []struct {
		name     string
		customer string
		items    []string
		total    int
		code     string
		want     Invoice
	}{
		{
			name:     "mit Code sale10",
			customer: "An",
			items:    []string{"Hemd", "Hut"},
			total:    200000,
			code:     "SALE10",
			want:     Invoice{Customer: "An", Items: []string{"Hemd", "Hut"}, Total: 180000},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
			if err != nil {
				t.Fatalf("unerwarteter Fehler: %v", err)
			}
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("BuildInvoice() unterscheidet sich (-want +got):\n%s", diff)
			}
		})
	}
}

cmp.Diff gibt einen leeren String zurück, wenn die beiden Werte gleich sind. Wenn sie unterschiedlich sind, erhältst du einen leicht zu lesenden Diff:

BuildInvoice() unterscheidet sich (-want +got):
  pricing.Invoice{
  	Customer: "An",
  	Items:    {"Hemd", "Hut"},
- 	Total:    180000,
+ 	Total:    200000,
  }

Das --Zeichen ist der erwartete Wert, das +-Zeichen ist der tatsächliche Wert. Die Konvention (-want +got) sollte in der Meldung klar angegeben werden, damit der Leser nicht raten muss.

8. Häufige Fehler und wann man es nicht verwenden sollte

Einige häufige Fehler beim Schreiben von Table-driven Tests:

  • Tabelle wird zu groß mit zu vielen Flag-Feldern. Wenn die Test-Struktur Felder wie setupMock bool, skipValidation bool oder expectCall int hat und der Schleifenrumpf voller if-Anweisungen ist, schreibst du Logik im Test. Teile dann in separate Testfunktionen auf.
  • Verwenden von t.Fatal außerhalb von t.Run. Ein fehlgeschlagener Fall führt dazu, dass die Ergebnisse aller nachfolgenden Fälle verloren gehen.
  • Map statt Slice verwenden, ohne die Konsequenzen zu verstehen. map[string]struct{...} verhindert doppelte Fallanamen und macht die Ausführungsreihenfolge zufällig, wodurch versteckte Abhängigkeiten zwischen Fällen leicht erkannt werden. Allerdings ist die Ausgabereihenfolge nicht fest vorgegeben. Beide Wege sind akzeptabel, solange sich das Team einigt.
  • Fehlermeldungen ohne Input. Wenn du nur t.Errorf("falsch") schreibst, musst der Debugger den Test-Code öffnen. Drucke immer Input, got und want.
  • Randfälle vergessen. Die Tabelle hilft dir, fehlende Fälle leicht zu sehen. Überprüfe noch einmal die Werte 0, negative Zahlen, leere Strings und Werte dicht an der Grenze (z. B. total: 50000 mit GIAM50K).

Du solltest Table-driven Tests nicht verwenden, wenn:

  • Jeder Fall benötigt eine sehr unterschiedliche Einrichtung, z. B. Mock gibt verschiedene Daten zurück oder die Aufruffolge ist unterschiedlich. Sie in eine Tabelle zu zwingen macht den Test nur schwer lesbar.
  • Du hast nur einen oder zwei Fälle. Eine einfache Testfunktion ist verständlicher.
  • Du musst zufällige Eingaben erkunden. Dann solltest du Fuzz-Tests in Betracht ziehen (func FuzzXxx(f *testing.F)), verfügbar ab Go 1.18. Du kannst die Test-Tabelle als Seed-Corpus über f.Add verwenden.

9. Fazit

Table-driven Tests helfen dir, viele Testfälle in einer Datentabelle zusammenzufassen und mit einer einzigen Schleife auszuführen. Mit t.Run wird jeder Fall zu einem unabhängigen Subtest. Du kannst jeden Fall einzeln mit -run ausführen, und ein fehlgeschlagener Fall stoppt die anderen nicht.

Wichtige Punkte zum Merken:

  • Jeder Fall sollte einen name, Eingaben und erwartete Ausgaben haben.
  • Überprüfe Fehler mit errors.Is und einem Feld wantErr error.
  • Verwende t.Parallel() nur wenn wirklich nötig, und achte auf die Schleifenvariablensemantikel vor Go 1.22.
  • Verwende cmp.Diff für den Vergleich komplexer Structs.
  • Wenn die Tabelle anfängt, Kontrolllogik zu enthalten, ist das ein Zeichen, dass du die Tests aufteilen solltest.
golangtestingtable-driven testunit test