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:
testsist ein Slice von anonymem Struct. Jedes Element ist ein Testfall mit Input (total,code) und erwartetem Output (want).- Das Feld
namehilft dir, zu erkennen, welcher Fall fehlgeschlagen ist. - Die
for-Schleife ruft die Funktion auf und vergleicht das Ergebnis. Fehlermeldungen nach dem MusterF(input) = got, want Xsind 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:
t.Fatalfbeendet den gesamten Test. Ein fehlgeschlagener Fall führt dazu, dass nachfolgende Fälle nicht ausgeführt werden.- 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
#01hinzu, 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
wantErrden Nullwertnil. Dann gibterrors.Is(nil, nil)truezurück. - Wenn die Funktion einen Fehler zurückgibt, während du das nicht erwartest, gibt
errors.Is(err, nil)falsezurück und der Test schlägt sofort fehl. errors.Isfunktioniert auch, wenn der Fehler mitfmt.Errorf("...: %w", err)verpackt ist. Der Vergleicherr == tc.wantErrfunktioniert 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 wieApplyDiscountist 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 booloderexpectCall inthat und der Schleifenrumpf vollerif-Anweisungen ist, schreibst du Logik im Test. Teile dann in separate Testfunktionen auf. - Verwenden von
t.Fatalaußerhalb vont.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: 50000mitGIAM50K).
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 überf.Addverwenden.
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.Isund einem FeldwantErr error. - Verwende
t.Parallel()nur wenn wirklich nötig, und achte auf die Schleifenvariablensemantikel vor Go 1.22. - Verwende
cmp.Difffür den Vergleich komplexer Structs. - Wenn die Tabelle anfängt, Kontrolllogik zu enthalten, ist das ein Zeichen, dass du die Tests aufteilen solltest.