Równoległe testy oparte na tabelach w Go

Przyspiesz testy Go za pomocą wykonywania równoległego

Page content

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.

Równoległe testy oparte na tabelach w Go - warunki wyścigu w Go

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:

  1. Testy udostępniają stan: zmienne globalne, singletony lub udostępnione zasoby
  2. Testy modyfikują udostępnione pliki: pliki tymczasowe, bazy danych testowe lub pliki konfiguracyjne
  3. Testy zależą od kolejności wykonywania: niektóre testy muszą być uruchamiane przed innymi
  4. Testy są już szybkie: narzut równoległości może przewyższać korzyści
  5. 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:

  1. Uruchom z -race: Zidentyfikuj warunki wyścigu
  2. Zmniejsz równoległość: go test -parallel 1, aby zobaczyć, czy błędy znikną
  3. Uruchom konkretne testy: go test -run TestName, aby izolować problem
  4. Dodaj logowanie: Użyj t.Log() do śledzenia kolejności wykonania
  5. 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 := tt przed t.Parallel()
  • Zapewnij niezależność testów bez udostępnionego stanu
  • Uruchamiaj testy z -race podczas 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

Zewnętrzne zasoby