Menu

Golang Testing: Unit-Tests, Tabellentests und Benchmarks

So testest du Go-Code mit dem Standardpaket testing und go test: _test.go-Dateien, TestXxx-Funktionen, t.Errorf gegenüber t.Fatalf, tabellengesteuerte Tests mit t.Run, Helfer und temporäre Verzeichnisse, Coverage, Benchmarks mit b.Loop und Example-Tests.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Der Code, der getestet wird

Die Testwerkzeuge von Go gehören zur Standard-Toolchain: das Paket testing und der Befehl go test. Kein Framework zu installieren, keine Assertion-Bibliothek nötig.

Die Beispiele auf dieser Seite testen eine kleine Funktion, Slugify, die einen Titel in einen URL-Slug umwandelt. Hier ist sie, ausführbar, mit einer selbst gebauten Prüfung in main:

Der Editor im Browser führt main aus; go test kann er nicht ausführen. Alles Weitere ist so geschrieben, wie es in einem echten Projekt steht, mit der Terminalausgabe nach jedem Befehl. Alles wurde mit Go 1.24 ausgeführt.

Der erste Test

In einem Projekt liegt die Funktion in einem Paket, und ihre Tests liegen daneben in einer Datei, deren Name auf _test.go endet:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

Die Regeln, nach denen go test arbeitet:

  • Nur Dateien, die auf _test.go enden, sind Testdateien. go build ignoriert sie, also landet Testcode nie in deinem Binary.
  • Ein Test ist eine Funktion namens TestXxx (der Teil nach Test darf nicht mit einem Kleinbuchstaben beginnen), die ein einziges *testing.T nimmt.
  • Ein Test besteht, außer er ruft eine der Methoden für Fehlschläge auf oder löst eine Panic aus.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

Das Format der Fehlermeldung Func(input) = got, want expected ist die Go-Konvention. Nimm die Eingabe in die Meldung auf: Ein Fehlschlag, der nur got "a", want "b" sagt, schickt dich zurück in den Code, um herauszufinden, welche Eingabe es war.

t.Errorf gegenüber t.Fatalf

MethodeMarkiert als fehlgeschlagenStoppt den Test
t.Error, t.Errorfjanein, läuft weiter
t.Fatal, t.Fatalfjaja, sofort
t.Log, t.Logfneinnein, Ausgabe nur mit -v oder bei einem Fehlschlag
t.Skip, t.Skipfneinja, als übersprungen gemeldet

Nimm standardmäßig Errorf, damit ein Lauf jedes falsche Feld meldet. Nimm Fatalf, wenn der Rest des Tests nicht funktionieren kann, typischerweise nach einem unerwarteten Fehler:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatal stoppt den Test über runtime.Goexit, muss also aus der eigenen Goroutine des Tests aufgerufen werden. Aus einer Goroutine, die du gestartet hast, meldest du mit t.Error und kehrst zurück.

Tabellengesteuerte Tests mit t.Run

Die meisten Go-Tests sind Tabellen: ein Slice von Fällen, eine Schleife und t.Run, das jedem Fall einen eigenen Namen gibt.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

Einen Fall hinzuzufügen ist eine Zeile. Jeder Subtest wird einzeln gemeldet, ein Fatal darin beendet nur diesen Subtest, und du kannst einen einzelnen per Namen ausführen. Führ alles mit -v aus:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

Leerzeichen in Subtest-Namen werden zu Unterstrichen. Schlägt ein Fall fehl, nennt die Ausgabe den Subtest und die Zeile:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

Seit Go 1.22 hat jeder Schleifendurchlauf sein eigenes tt, also sehen Closures in t.Run den richtigen Fall, auch wenn Subtests parallel laufen. Älterer Code hat deshalb oft tt := tt am Anfang des Schleifenrumpfs; das ist nicht mehr nötig.

Um Subtests parallel auszuführen, ruf t.Parallel() am Anfang der Subtest-Funktion auf. Mach das nur, wenn die Fälle unabhängig und langsam genug sind, dass es sich lohnt.

Flags von go test, die du nutzen wirst

BefehlMacht
go test -vgibt Name, Ergebnis und t.Log-Ausgabe jedes Tests aus
go test -run TestSlugifyführt Tests aus, deren Name zum regulären Ausdruck passt
go test -run 'TestSlugifyTable/empty'führt einen Subtest aus
go test -count=1ignoriert zwischengespeicherte Ergebnisse und führt erneut aus
go test -raceläuft mit dem Data Race Detector
go test -shortsagt langen Tests, dass sie sich selbst überspringen sollen (if testing.Short() { t.Skip() })
go test -failfaststoppt nach dem ersten fehlgeschlagenen Test
go test -timeout 30sschlägt fehl, wenn der Lauf länger dauert (Standard 10 Minuten)
go test -covergibt die Anweisungsabdeckung aus
go test -bench=.führt zusätzlich Benchmarks aus

Nennst du Pakete (go test ., go test ./...), speichert go test Ergebnisse für Pakete zwischen, deren Code und Eingaben sich nicht geändert haben, und gibt dahinter (cached) aus. Ein schlichtes go test ohne Argumente cacht nie. Der Cache verfolgt die Dateien und Umgebungsvariablen, die ein Test über das Paket os liest, aber keinen externen Zustand wie eine Datenbank oder einen Netzwerkdienst. Ein Test, der von so etwas abhängt, kann also aus dem Cache bestehen, obwohl er jetzt fehlschlagen würde; -count=1 erzwingt einen echten Lauf.

Helfer, temporäre Verzeichnisse und Aufräumen

Der Aufruf writeFile in TestCountWords oben ist ein Test-Helfer:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper() markiert die Funktion als Helfer, damit ein Fehlschlag an der Zeile im Test gemeldet wird, die ihn aufgerufen hat, nicht im Helfer.
  • t.TempDir() legt ein frisches Verzeichnis an und löscht es, wenn der Test endet. Tests müssen nie selbst Dateien aufräumen.
  • t.Cleanup(func() { ... }) registriert jedes andere Aufräumen (einen Server schließen, eine Tabelle löschen). Cleanups laufen nach dem Test und seinen Subtests, das zuletzt registrierte zuerst.
  • t.Setenv("KEY", "value") setzt eine Umgebungsvariable nur für diesen Test und stellt sie danach wieder her.
  • t.Context() (Go 1.24) gibt einen Context zurück, der kurz vor den Cleanups abgebrochen wird.

Fixture-Dateien für Tests gehören in ein Verzeichnis namens testdata neben den Tests. Das Tool go ignoriert es als Paket, und Tests laufen mit dem Paketverzeichnis als Arbeitsverzeichnis, also funktioniert os.ReadFile("testdata/input.json").

Fehler und HTTP-Handler testen

Prüf Fehler mit errors.Is oder errors.As, genauso wie es der aufrufende Code tun würde:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

Fehler-Strings zu vergleichen bricht, sobald jemand eine Meldung umformuliert.

Für HTTP-Handler liefert net/http/httptest einen gefälschten ResponseWriter, mit dem du den Handler direkt aufrufen kannst, ganz ohne Netzwerk:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer startet einen echten Server auf localhost, um Client-Code zu testen. Die Seite zum HTTP-Client nutzt ihn für jedes Beispiel.

Coverage

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

Coverage zeigt dir, welche Zeilen nie gelaufen sind, und das hilft, ungetestete Zweige zu finden. Eine hohe Zahl sagt dir nicht, dass die Prüfungen etwas taugen: Ein Test, der jede Funktion aufruft und nichts prüft, erreicht 100 %.

Benchmarks

Ein Benchmark ist func BenchmarkXxx(b *testing.B). Go 1.24 hat b.Loop eingeführt, das jetzt die empfohlene Form ist:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop führt den Rumpf so oft aus, wie für eine stabile Messung nötig ist, nimmt Setup-Code vor der Schleife aus der Zeitmessung heraus und verhindert, dass der Compiler den Aufruf wegoptimiert. Code, der vor Go 1.24 geschrieben wurde, nutzt for i := 0; i < b.N; i++. Das funktioniert weiterhin, braucht aber nach teurem Setup ein b.ResetTimer() und kann durch die Eliminierung toten Codes getäuscht werden.

Mit einem schlichten go test laufen Benchmarks nicht. Fordere sie an und überspring die normalen Tests mit -run='^$':

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

Die Spalten sind: Name mit angehängtem GOMAXPROCS, ausgeführte Iterationen, Zeit pro Aufruf, allokierte Bytes pro Aufruf, Allokationen pro Aufruf. Die Zahlen hängen vom Rechner ab; vergleiche Läufe auf demselben. Um vorher und nachher zuverlässig zu vergleichen, führ jede Seite mehrmals mit -count=10 aus und gib beide Ausgaben an benchstat (golang.org/x/perf/cmd/benchstat).

Examples sind auch Tests

Eine Example-Funktion gibt etwas aus und deklariert die erwartete Ausgabe in einem Kommentar. go test führt sie aus und schlägt fehl, wenn die Ausgabe abweicht, und go doc und pkg.go.dev zeigen sie als Dokumentation:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

Der Paketname textutil_test macht das zu einem externen Test: Er kann nur die exportierte API nutzen, wie ein echter Aufrufer. Solche Dateien dürfen im selben Verzeichnis wie das Paket liegen. Ohne einen Kommentar // Output: wird das Example kompiliert, aber nicht ausgeführt. Nimm // Unordered output:, wenn Zeilen in beliebiger Reihenfolge erscheinen können.

Fuzzing in Kürze

Go 1.18 hat Fuzz-Tests eingeführt, die Eingaben erzeugen, um Abstürze und verletzte Invarianten zu finden:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

Ein schlichtes go test führt nur die Seed-Eingaben aus (die Werte aus f.Add und alle gespeicherten Dateien unter testdata/fuzz). go test -fuzz=FuzzSlugify erzeugt immer neue, bis es einen Fehlschlag findet oder du es stoppst, und speichert fehlschlagende Eingaben unter testdata/fuzz, damit sie zu normalen Testfällen werden.

Häufige Fehler

  • Testdatei am falschen Ort oder falsch benannt. Sie muss auf _test.go enden und im Paketverzeichnis liegen. slug_tests.go wird ins Paket kompiliert, nicht als Test ausgeführt.
  • Kleinbuchstabe nach Test. Testslugify ist kein Test. TestSlugify und Test_slugify sind es.
  • t.Fatal aus einer anderen Goroutine. Melde dort mit t.Error und warte auf die Goroutine, bevor der Test zurückkehrt.
  • Meldungen ohne die Eingabe. Nenne, was hineinging, was herauskam und was erwartet wurde.
  • Einem gecachten Bestehen vertrauen. Nimm -count=1, wenn ein Test von etwas außerhalb des Pakets abhängt.
  • Tests, die von der Reihenfolge anderer Tests oder von geteilten globalen Variablen abhängen. Jeder Test sollte seinen eigenen Zustand aufbauen, und genau dafür gibt es t.TempDir, t.Setenv und t.Cleanup.

Häufig gestellte Fragen

Wie schreibe ich in Go einen Unit-Test?

Leg im selben Verzeichnis wie der Code eine Datei an, deren Name auf _test.go endet, importiere testing und schreib eine Funktion func TestName(t *testing.T), die deinen Code aufruft und Abweichungen mit t.Errorf meldet. Führ go test in diesem Verzeichnis aus oder go test ./... für das ganze Modul. Ein Framework oder eine Assertion-Bibliothek brauchst du nicht.

Was ist der Unterschied zwischen t.Error und t.Fatal in Go?

t.Error und t.Errorf markieren den Test als fehlgeschlagen und lassen ihn weiterlaufen, sodass ein Lauf mehrere Probleme melden kann. t.Fatal und t.Fatalf markieren ihn als fehlgeschlagen und stoppen den Test sofort. Nimm Fatal, wenn Weitermachen keinen Sinn ergibt, etwa nach einem unerwarteten Fehler, der das Ergebnis unbrauchbar macht.

Wie führe ich in Go einen einzelnen Test aus?

Übergib -run einen regulären Ausdruck: go test -run TestSlugify führt jeden Test aus, dessen Name passt, und go test -run 'TestSlugifyTable/punctuation' führt einen einzelnen Subtest aus (Leerzeichen in Subtest-Namen werden zu Unterstrichen). Füg -v hinzu, um Namen und Ergebnis jedes Tests zu sehen, und -count=1, um den Test-Cache zu umgehen.

Wie schreibe ich in Go einen Benchmark?

Schreib func BenchmarkName(b *testing.B) in eine _test.go-Datei und setz den zu messenden Code in eine Schleife for b.Loop() { ... } (Go 1.24; älterer Code nutzt for i := 0; i < b.N; i++). Führ ihn mit go test -bench=. -benchmem aus, das Nanosekunden, Bytes und Allokationen pro Operation meldet.

Wie sehe ich in Go die Testabdeckung?

go test -cover gibt den Prozentsatz der Anweisungen aus, die die Tests ausgeführt haben. Für Details schreibst du mit go test -coverprofile=cover.out ein Profil und führst dann go tool cover -func=cover.out für Zahlen pro Funktion aus oder go tool cover -html=cover.out, um abgedeckte und nicht abgedeckte Zeilen im Browser zu sehen.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S