Równoległe testy oparte na tabelach w Go
Przyspiesz testy Go za pomocą wykonywania równoległego
Testy oparte na tabelach są idiomicznym sposobem w Go na testowanie wielu scenariuszy w sposób efektywny.
Połączone z wykonywaniem testów równolegle przy użyciu t.Parallel(), możesz znacząco zmniejszyć czas działania zestawu testów, szczególnie dla operacji ograniczonych przez I/O.
Jednak równoległe testy wprowadzają unikalne wyzwania związane z warunkami wyścigu i izolacją testów, które wymagają ostrożnego podejścia.

Zrozumienie wykonywania testów równolegle
Pakiet testowy Go oferuje wbudowaną obsługę równoległego wykonywania testów poprzez metodę t.Parallel(). Gdy test wywołuje t.Parallel(), sygnalizuje to uruchamiającemu testy, że ten test może bezpiecznie zostać wykonany równolegle z innymi testami równoległymi. To jest szczególnie potężne, gdy jest połączone z testami opartymi na tabelach, gdzie masz wiele niezależnych przypadków testowych, które mogą być wykonywane jednocześnie.
Domyślna równoległość jest kontrolowana przez GOMAXPROCS, który zwykle jest równy liczbie rdzeni procesora na Twoim komputerze. Możesz to zmienić za pomocą flagi -parallel: go test -parallel 4 ogranicza testy równoległe do 4, niezależnie od liczby rdzeni procesora. Jest to przydatne do kontroli zużycia zasobów lub gdy testy mają konkretne wymagania dotyczące współbieżności.
Dla nowych programistów w Go, zrozumienie podstaw jest kluczowe. Nasz przewodnik po najlepszych praktykach testowania jednostkowego w Go obejmuje testy oparte na tabelach, podtesty i podstawy pakietu testing, które stanowią fundamenty dla wykonywania testów równolegle.
Podstawowy wzorzec testów opartych na tabelach w trybie równoległym
Oto poprawny wzorzec dla testów opartych na tabelach w trybie równoległym:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"dodawanie", 2, 3, "+", 5, false},
{"odejmowanie", 5, 3, "-", 2, false},
{"mnożenie", 4, 3, "*", 12, false},
{"dzielenie", 10, 2, "/", 5, false},
{"dzielenie przez zero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
tt := tt // Przechwycenie zmiennej pętli
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Włącz wykonywanie równoległe
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
Krytyczna linia to tt := tt przed t.Run(). Przechwycza bieżącą wartość zmiennej pętli, zapewniając, że każdy podtest równoległy operuje na własnej kopii danych przypadku testowego.
Problem przechwycenia zmiennej pętli
To jedno z najczęściej występujących pułapek przy użyciu t.Parallel() w testach opartych na tabelach. W Go, zmienna pętli tt jest udostępniana wszystkim iteracjom. Gdy podtesty działają równolegle, mogą wszystkie odwoływać się do tej samej zmiennej tt, która jest nadpisywana w miarę kontynuowania pętli. To prowadzi do warunków wyścigu i nieprzewidywalnych błędów testów.
Niepoprawne (warunek wyścigu):
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Wszystkie podtesty mogą zobaczyć tę samą wartość tt!
result := Calculate(tt.a, tt.b, tt.op)
})
}
Poprawne (przechwyciona zmienna):
for _, tt := range tests {
tt := tt // Przechwycenie zmiennej pętli
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Każdy podtest ma własną kopię tt
result := Calculate(tt.a, tt.b, tt.op)
})
}
Przypisanie tt := tt tworzy nową zmienną zakresu iteracji pętli, zapewniając, że każdy wątek ma własną kopię danych przypadku testowego.
Zapewnianie niezależności testów
Aby testy równoległe działały poprawnie, każdy test musi być całkowicie niezależny. Nie powinny:
- Udostępniać stanu globalnego lub zmiennych
- Modyfikować udostępniane zasoby bez synchronizacji
- Zależne od kolejności wykonania
- Dostępować do tych samych plików, baz danych lub zasobów sieciowych bez koordynacji
Przykład niezależnych testów równoległych:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"poprawny adres e-mail", "user@example.com", false},
{"nieprawidłowy format", "not-an-email", true},
{"brak domeny", "user@", true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
Każdy przypadek testowy operuje na własnych danych wejściowych bez udostępnianego stanu, co czyni to bezpiecznym do wykonywania równolegle.
Wykrywanie warunków wyścigu
Go oferuje potężny detektor wyścigów, aby wykryć błędy w równoległych testach. Zawsze uruchamiaj swoje testy równoległe z flagą -race podczas rozwoju:
go test -race ./...
Detektor wyścigów zgłosi wszystkie jednoczesne dostępy do pamięci udostępnionej bez odpowiedniej synchronizacji. Jest to kluczowe do wykrywania subtelnych błędów, które mogą pojawić się tylko w określonych warunkach czasowych.
Przykład warunku wyścigu:
var counter int // Stan udostępniony - SZKODLIWE!
func TestIncrement(t *testing.T) {
tests := []struct {
name string
want int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
counter++ // WARUNEK WYŚCIGU!
if counter != tt.want {
t.Errorf("counter = %d, want %d", counter, tt.want)
}
})
}
}
Uruchamiając to z -race zostanie wykryte jednoczesne modyfikowanie counter. Poprawką jest zrobienie tak, aby każdy test był niezależny, używając zmiennych lokalnych zamiast stanu udostępnionego.
Korzyści wydajnościowe
Wykonanie równoległe może znacząco zmniejszyć czas działania zestawu testów. Prędkość zależy od:
- Liczby rdzeni procesora: Więcej rdzeni pozwala na jednoczesne wykonanie większej liczby testów
- Charakterystyki testów: Testy ograniczone przez I/O korzystają bardziej niż testy ograniczone przez CPU
- Liczby testów: Większe zestawy testów mają większe oszczędności czasowe
Mierzenie wydajności:
# Wykonanie sekwencyjne
go test -parallel 1 ./...
# Wykonanie równoległe (domyślne)
go test ./...
# Wykonanie równoległe z niestandardową równoległością
go test -parallel 8 ./...
Dla zestawów testów z wieloma operacjami I/O (zapytania do bazy danych, żądania HTTP, operacje plików), często można uzyskać przyspieszenie 2-4 razy na nowoczesnych systemach wielordzeniowych. Testy ograniczone przez CPU mogą nie korzystać tak bardzo z powodu rywalizacji o zasoby CPU.
Kontrolowanie równoległości
Masz kilka opcji do kontroli równoległego wykonywania testów:
1. Ograniczenie maksymalnej liczby równoległych testów:
go test -parallel 4 ./...
2. Ustawienie GOMAXPROCS:
GOMAXPROCS=2 go test ./...
3. Wybórowe wykonywanie równoległe:
Oznacz tylko konkretne testy za pomocą t.Parallel(). Testy bez tego wywołania są wykonywane sekwencyjnie, co jest przydatne, gdy niektóre testy muszą być uruchamiane w określonej kolejności lub udostępniają zasoby.
4. Warunkowe wykonywanie równoległe:
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("pomijanie drogiego testu w trybie krótkim")
}
t.Parallel()
// Logika drogiego testu
}
Powszechne wzorce i najlepsze praktyki
Wzorzec 1: Konfiguracja przed wykonywaniem równoległym
Jeśli potrzebujesz konfiguracji udostępnionej dla wszystkich przypadków testowych, wykonaj ją przed pętlą:
func TestWithSetup(t *testing.T) {
// Kod konfiguracji uruchamia się raz, przed równoległym wykonaniem
db := setupTestDatabase(t)
defer db.Close()
tests := []struct {
name string
id int
}{
{"user1", 1},
{"user2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Każdy test korzysta z db niezależnie
user := db.GetUser(tt.id)
// Logika testu...
})
}
}
Wzorzec 2: Konfiguracja dla każdego testu
Dla testów wymagających izolowanej konfiguracji, wykonaj ją wewnątrz każdego podtestu:
func TestWithPerTestSetup(t *testing.T) {
tests := []struct {
name string
data string
}{
{"test1", "data1"},
{"test2", "data2"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Każdy test otrzymuje własną konfigurację
tempFile := createTempFile(t, tt.data)
defer os.Remove(tempFile)
// Logika testu...
})
}
}
Wzorzec 3: Mieszane sekwencyjne i równoległe testy
Możesz mieszać testy sekwencyjne i równoległe w tym samym pliku:
func TestSequential(t *testing.T) {
// Brak t.Parallel() - wykonywane sekwencyjnie
// Dobre dla testów, które muszą być uruchamiane w określonej kolejności
}
func TestParallel(t *testing.T) {
tests := []struct{ name string }{{"test1"}, {"test2"}}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Te wykonywane są równolegle
})
}
}
Kiedy NIE używać równoległego wykonywania
Równoległe wykonywanie nie zawsze jest odpowiednie. Unikaj go, gdy:
- Testy udostępniają stan: zmienne globalne, singletony lub udostępnione zasoby
- Testy modyfikują udostępnione pliki: pliki tymczasowe, bazy danych testowe lub pliki konfiguracyjne
- Testy zależą od kolejności wykonywania: niektóre testy muszą być uruchamiane przed innymi
- Testy są już szybkie: narzut równoległości może przewyższać korzyści
- Ograniczenia zasobów: testy zużywają zbyt dużo pamięci lub CPU, gdy są równolegle uruchamiane
Dla testów związanych z bazą danych, rozważ użycie wycofywania transakcji lub osobnych baz danych testowych dla każdego testu. Nasz przewodnik po wzorcach baz danych dla aplikacji wieloudomowych w Go pokazuje strategie izolacji, które dobrze działają wraz z testowaniem równoległym.
Zaawansowane: Testowanie kodu współbieżnego
Gdy testujesz sam kod współbieżny (nie tylko testy w trybie równoległym), potrzebujesz dodatkowych technik:
func TestConcurrentOperation(t *testing.T) {
tests := []struct {
name string
goroutines int
}{
{"2 wątki", 2},
{"10 wątków", 10},
{"100 wątków", 100},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var wg sync.WaitGroup
results := make(chan int, tt.goroutines)
for i := 0; i < tt.goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- performOperation()
}()
}
wg.Wait()
close(results)
// Weryfikacja wyników
count := 0
for range results {
count++
}
if count != tt.goroutines {
t.Errorf("oczekiwano %d wyników, otrzymano %d", tt.goroutines, count)
}
})
}
}
Zawsze uruchamiaj takie testy z -race, aby wykryć warunki wyścigu w kodzie pod testem.
Integracja z CI/CD
Testy równoległe integrują się płynnie z potokami CI/CD. Większość systemów CI oferuje wiele rdzeni procesora, co czyni równoległe wykonywanie bardzo korzystne:
# Przykład GitHub Actions
- name: Uruchom testy
run: |
go test -race -coverprofile=coverage.out -parallel 4 ./...
go tool cover -html=coverage.out -o coverage.html
Flaga -race jest szczególnie ważna w CI, aby wykryć błędy współbieżności, które mogą nie pojawiać się w lokalnym rozwoju.
Debugowanie błędów testów równoległych
Gdy testy równoległe nieprzewidywalnie zawalidują się, debugowanie może być trudne:
- Uruchom z
-race: Zidentyfikuj warunki wyścigu - Zmniejsz równoległość:
go test -parallel 1, aby zobaczyć, czy błędy znikną - Uruchom konkretne testy:
go test -run TestName, aby izolować problem - Dodaj logowanie: Użyj
t.Log()do śledzenia kolejności wykonania - Sprawdź na udostępnionej stanie: Sprawdź zmienne globalne, singletony lub udostępnione zasoby
Jeśli testy przechodzą sekwencyjnie, ale zawalidują się równolegle, prawdopodobnie masz problem z warunkiem wyścigu lub udostępnionym stanem.
Przykład z życia: Testowanie handlerów HTTP
Oto praktyczny przykład testowania handlerów HTTP w trybie równoległym:
func TestHTTPHandlers(t *testing.T) {
router := setupRouter()
tests := []struct {
name string
method string
path string
statusCode int
}{
{"GET użytkownicy", "GET", "/users", 200},
{"GET użytkownik po ID", "GET", "/users/1", 200},
{"POST użytkownik", "POST", "/users", 201},
{"DELETE użytkownik", "DELETE", "/users/1", 204},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.statusCode {
t.Errorf("oczekiwano statusu %d, otrzymano %d", tt.statusCode, w.Code)
}
})
}
}
Każdy test używa httptest.NewRecorder(), który tworzy izolowany rejestrujący wynik, czyniąc te testy bezpiecznymi do wykonywania równolegle.
Podsumowanie
Równoległe wykonywanie testów opartych na tabelach to potężna technika do zmniejszania czasu działania zestawu testów w Go. Kluczem do sukcesu jest zrozumienie wymagania przechwycenia zmiennej pętli, zapewnianie niezależności testów i użycie detektora wyścigów do wykrywania problemów współbieżności wczesnie.
Pamiętaj:
- Zawsze przechwyciaj zmienne pętli:
tt := ttprzedt.Parallel() - Zapewnij niezależność testów bez udostępnionego stanu
- Uruchamiaj testy z
-racepodczas rozwoju - Kontroluj równoległość za pomocą flagi
-parallel, kiedy to potrzebne - Unikaj równoległego wykonywania dla testów, które udostępniają zasoby
Przyjmując te praktyki, możesz bezpiecznie wykorzystać równoległe wykonywanie, aby przyspieszyć swoje zestawy testów, jednocześnie utrzymując niezawodność. Dla więcej wzorców testowania w Go, zobacz nasz szczegółowy przewodnik po testowaniu jednostkowym w Go, który obejmuje testy oparte na tabelach, mocki i inne istotne techniki testowania.
Gdy budujesz większe aplikacje w Go, te praktyki testowania stosują się w różnych dziedzinach. Na przykład, gdy budujesz aplikacje CLI z Cobra & Viper, używasz podobnych wzorców testów równoległych do testowania handlerów poleceń i analizy konfiguracji.
Przydatne linki
- Arkusz wskazówek dla Go
- Testowanie jednostkowe w Go: struktura i najlepsze praktyki
- Tworzenie aplikacji CLI w Go z Cobra & Viper
- Wzorce baz danych dla aplikacji wieloudomowych z przykładami w Go
- Porównanie ORMów Go dla PostgreSQL: GORM vs Ent vs Bun vs sqlc