Menu

Testowanie w Golang: testy jednostkowe, tabelaryczne i benchmarki

Jak testować kod w Go standardowym pakietem testing i poleceniem go test: pliki _test.go, funkcje TestXxx, t.Errorf a t.Fatalf, testy tabelaryczne z t.Run, funkcje pomocnicze i katalogi tymczasowe, pokrycie kodu, benchmarki z b.Loop i testy przykładów.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Testowany kod

Narzędzia do testowania w Go są częścią standardowego zestawu narzędzi: pakiet testing i polecenie go test. Nie trzeba instalować frameworka ani biblioteki asercji.

Przykłady na tej stronie testują jedną małą funkcję, Slugify, która zamienia tytuł na slug do adresu URL. Oto ona, gotowa do uruchomienia, z ręcznie napisanym sprawdzeniem w main:

Edytor w przeglądarce uruchamia main; nie potrafi uruchomić go test. Wszystko poniżej jest zapisane tak, jak wygląda w prawdziwym projekcie, a po każdym poleceniu pokazany jest wynik z terminala. Całość uruchomiono na Go 1.24.

Pierwszy test

W projekcie funkcja znajduje się w pakiecie, a jej testy leżą obok, w pliku, którego nazwa kończy się na _test.go:

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

Zasady, których używa go test:

  • Plikami testowymi są tylko pliki z końcówką _test.go. go build je ignoruje, więc kod testów nigdy nie trafia do twojej binarki.
  • Test to funkcja o nazwie TestXxx (część po Test nie może zaczynać się małą literą), która przyjmuje jeden *testing.T.
  • Test przechodzi, chyba że wywoła jedną z metod zgłaszających porażkę albo wywoła panic.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

Format komunikatu o błędzie Func(input) = got, want expected to konwencja Go. Umieść w komunikacie dane wejściowe: błąd, który mówi tylko got "a", want "b", odsyła cię z powrotem do kodu, żeby sprawdzić, o które wejście chodziło.

t.Errorf a t.Fatalf

MetodaOznacza porażkęZatrzymuje test
t.Error, t.Errorftaknie, test działa dalej
t.Fatal, t.Fatalftaktak, natychmiast
t.Log, t.Logfnienie, wypisuje tylko z -v lub przy porażce
t.Skip, t.Skipfnietak, zgłoszony jako pominięty

Domyślnie używaj Errorf, żeby jedno uruchomienie zgłosiło każde złe pole. Używaj Fatalf, gdy reszta testu nie może zadziałać, zwykle po nieoczekiwanym błędzie:

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 zatrzymuje test przez wywołanie runtime.Goexit, więc trzeba go wywoływać z własnej goroutine testu. Z goroutine uruchomionej wewnątrz testu zgłoś błąd przez t.Error i wróć.

Testy tabelaryczne z t.Run

Większość testów w Go to tabele: slice przypadków, jedna pętla i t.Run, który nadaje każdemu przypadkowi własną nazwę.

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

Dodanie przypadku to jedna linia. Każdy podtest jest raportowany osobno, Fatal wewnątrz jednego kończy tylko ten podtest, a pojedynczy podtest można uruchomić po nazwie. Uruchom wszystko z -v:

$ 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

Spacje w nazwach podtestów zamieniają się w podkreślenia. Gdy przypadek nie przechodzi, wynik podaje nazwę podtestu i linię:

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

Od Go 1.22 każda iteracja pętli ma własne tt, więc domknięcia wewnątrz t.Run widzą właściwy przypadek, nawet gdy podtesty działają równolegle. Starszy kod często ma z tego powodu tt := tt na początku ciała pętli; nie jest to już potrzebne.

Aby uruchamiać podtesty równolegle, wywołaj t.Parallel() na początku funkcji podtestu. Rób to tylko wtedy, gdy przypadki są niezależne i na tyle wolne, żeby miało to znaczenie.

Flagi go test, których będziesz używać

PolecenieDziałanie
go test -vwypisuje nazwę i wynik każdego testu oraz wyjście t.Log
go test -run TestSlugifyuruchamia testy, których nazwy pasują do wyrażenia regularnego
go test -run 'TestSlugifyTable/empty'uruchamia jeden podtest
go test -count=1ignoruje wyniki z cache i uruchamia ponownie
go test -raceuruchamia z detektorem wyścigów danych
go test -shortprosi długie testy, żeby się pominęły (if testing.Short() { t.Skip() })
go test -failfastzatrzymuje się po pierwszym nieudanym teście
go test -timeout 30skończy się porażką, jeśli uruchomienie trwa dłużej (domyślnie 10 minut)
go test -coverwypisuje pokrycie instrukcji
go test -bench=.uruchamia też benchmarki

Gdy podajesz pakiety (go test ., go test ./...), go test zapisuje w cache wyniki pakietów, których kod i dane wejściowe się nie zmieniły, i wypisuje przy nich (cached). Zwykłe go test bez argumentów nigdy nie używa cache. Cache śledzi pliki i zmienne środowiskowe, które test odczytuje przez pakiet os, ale nie stan zewnętrzny, taki jak baza danych czy usługa sieciowa, więc test, który od niego zależy, może przejść z cache, choć teraz by nie przeszedł; -count=1 wymusza prawdziwe uruchomienie.

Funkcje pomocnicze, katalogi tymczasowe i sprzątanie

Wywołanie writeFile w TestCountWords powyżej to funkcja pomocnicza testu:

// 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() oznacza funkcję jako pomocniczą, więc porażka jest zgłaszana w linii testu, który ją wywołał, a nie wewnątrz funkcji pomocniczej.
  • t.TempDir() tworzy nowy katalog i usuwa go po zakończeniu testu. Testy nigdy nie muszą same sprzątać plików.
  • t.Cleanup(func() { ... }) rejestruje dowolne inne sprzątanie (zamknięcie serwera, usunięcie tabeli). Funkcje sprzątające działają po teście i jego podtestach, od ostatnio zarejestrowanej.
  • t.Setenv("KEY", "value") ustawia zmienną środowiskową tylko dla tego testu i potem przywraca poprzednią wartość.
  • t.Context() (Go 1.24) zwraca kontekst, który jest anulowany tuż przed uruchomieniem funkcji sprzątających.

Pliki z danymi testowymi umieszcza się w katalogu testdata obok testów. Narzędzie go nie traktuje go jako pakietu, a testy działają z katalogiem pakietu jako katalogiem roboczym, więc os.ReadFile("testdata/input.json") działa.

Testowanie błędów i handlerów HTTP

Sprawdzaj błędy przez errors.Is lub errors.As, tak samo jak zrobiłby to kod wywołujący:

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

Porównywanie tekstów błędów psuje się, gdy tylko ktoś zmieni treść komunikatu.

Dla handlerów HTTP net/http/httptest daje sztuczny ResponseWriter, więc możesz wywołać handler bezpośrednio, bez sieci:

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 uruchamia prawdziwy serwer na localhost do testowania kodu klienta. Strona o kliencie HTTP używa go w każdym przykładzie.

Pokrycie kodu

$ 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

Pokrycie pokazuje, które linie nigdy się nie wykonały, co pomaga znaleźć nieprzetestowane gałęzie. Wysoka liczba nie mówi jednak, czy asercje są dobre: test, który wywołuje każdą funkcję i niczego nie sprawdza, osiąga 100%.

Benchmarki

Benchmark to func BenchmarkXxx(b *testing.B). Go 1.24 dodało b.Loop, które jest teraz zalecaną formą:

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

b.Loop wykonuje ciało tyle razy, ile trzeba do stabilnego pomiaru, nie wlicza do czasu kodu przygotowującego przed pętlą i nie pozwala kompilatorowi wyoptymalizować wywołania. Kod napisany przed Go 1.24 używa for i := 0; i < b.N; i++, co nadal działa, ale wymaga b.ResetTimer() po kosztownym przygotowaniu i może zostać oszukane przez eliminację martwego kodu.

Benchmarki nie uruchamiają się przy zwykłym go test. Poproś o nie i pomiń zwykłe testy przez -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

Kolumny to: nazwa z dopisanym GOMAXPROCS, liczba wykonanych iteracji, czas na wywołanie, bajty zaalokowane na wywołanie, alokacje na wywołanie. Liczby zależą od maszyny; porównuj uruchomienia na tej samej. Aby rzetelnie porównać stan przed zmianą i po niej, uruchom każdą wersję kilka razy z -count=10 i przekaż oba wyniki do benchstat (golang.org/x/perf/cmd/benchstat).

Przykłady też są testami

Funkcja przykładu coś wypisuje i deklaruje oczekiwany wynik w komentarzu. go test ją uruchamia i zgłasza porażkę, jeśli wynik się różni, a go doc i pkg.go.dev pokazują ją jako dokumentację:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

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

Nazwa pakietu textutil_test sprawia, że to test zewnętrzny: może używać tylko eksportowanego API, tak jak prawdziwy wywołujący. Takie pliki mogą leżeć w tym samym katalogu co pakiet. Bez komentarza // Output: przykład jest kompilowany, ale nie uruchamiany. Użyj // Unordered output:, gdy linie mogą pojawić się w dowolnej kolejności.

Fuzzing w skrócie

Go 1.18 dodało testy fuzz, które generują dane wejściowe, żeby znaleźć awarie i złamane niezmienniki:

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

Zwykłe go test uruchamia tylko dane startowe (wartości z f.Add i zapisane pliki w testdata/fuzz). go test -fuzz=FuzzSlugify generuje nowe dane, dopóki nie znajdzie błędu albo go nie zatrzymasz, i zapisuje dane powodujące porażkę w testdata/fuzz, dzięki czemu stają się zwykłymi przypadkami testowymi.

Typowe błędy

  • Plik testowy w złym miejscu lub ze złą nazwą. Musi kończyć się na _test.go i leżeć w katalogu pakietu. slug_tests.go jest kompilowany do pakietu, a nie uruchamiany jako testy.
  • Mała litera po Test. Testslugify nie jest testem. TestSlugify i Test_slugify są.
  • t.Fatal z innej goroutine. Tam zgłaszaj błąd przez t.Error i poczekaj na goroutine, zanim test się zakończy.
  • Komunikaty bez danych wejściowych. Podaj, co zostało przekazane, co wyszło i czego oczekiwano.
  • Zaufanie wynikowi z cache. Użyj -count=1, gdy test zależy od czegokolwiek spoza pakietu.
  • Testy zależne od kolejności innych testów lub współdzielonych zmiennych globalnych. Każdy test powinien przygotować własny stan i właśnie do tego służą t.TempDir, t.Setenv i t.Cleanup.

Najczęściej zadawane pytania

Jak napisać test jednostkowy w Go?

Utwórz plik z końcówką _test.go w tym samym katalogu co kod, zaimportuj testing i napisz funkcję func TestName(t *testing.T), która wywołuje twój kod i zgłasza niezgodności przez t.Errorf. Uruchom go test w tym katalogu albo go test ./... dla całego modułu. Nie potrzebujesz żadnego frameworka ani biblioteki asercji.

Czym różni się t.Error od t.Fatal w Go?

t.Error i t.Errorf oznaczają test jako nieudany i wykonują go dalej, więc jedno uruchomienie może zgłosić kilka problemów. t.Fatal i t.Fatalf oznaczają go jako nieudany i natychmiast zatrzymują test. Używaj Fatal, gdy kontynuowanie nie ma sensu, na przykład po nieoczekiwanym błędzie, przez który wynik jest bezużyteczny.

Jak uruchomić pojedynczy test w Go?

Przekaż wyrażenie regularne do -run: go test -run TestSlugify uruchamia każdy test, którego nazwa pasuje, a go test -run 'TestSlugifyTable/punctuation' uruchamia jeden podtest (spacje w nazwach podtestów zamieniają się w podkreślenia). Dodaj -v, żeby zobaczyć nazwę i wynik każdego testu, oraz -count=1, żeby pominąć cache testów.

Jak napisać benchmark w Go?

Napisz func BenchmarkName(b *testing.B) w pliku _test.go i umieść mierzony kod w pętli for b.Loop() { ... } (Go 1.24; starszy kod używa for i := 0; i < b.N; i++). Uruchom go przez go test -bench=. -benchmem, co pokaże nanosekundy, bajty i alokacje na operację.

Jak sprawdzić pokrycie kodu testami w Go?

go test -cover wypisuje procent instrukcji wykonanych przez testy. Aby zobaczyć szczegóły, zapisz profil przez go test -coverprofile=cover.out, a potem uruchom go tool cover -func=cover.out, żeby dostać liczby dla każdej funkcji, albo go tool cover -html=cover.out, żeby zobaczyć pokryte i niepokryte linie w przeglądarce.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ