Testowanie jednostkowe w Go: struktura i najlepsze praktyki

Testowanie w Go od podstaw po zaawansowane wzorce

Page content

wbudowanego pakietu testowego w Go daje potężny, minimalistyczny framework do pisania testów jednostkowych bez zależności zewnętrznych. Oto podstawy testowania, struktura projektu i zaawansowane wzorce do tworzenia niezawodnych aplikacji w Go.

Testowanie jednostkowe w Go to super

Dlaczego testowanie ma znaczenie w Go

Filozofia Go podkreśla prostotę i niezawodność. Biblioteka standardowa zawiera pakiet testing, który sprawia, że testowanie jednostkowe jest pierwszym klasą obywatelstwa w ekosystemie Go. Dobrze przetestowany kod w Go poprawia utrzymanie, wykrywa błędy wczesnie i dostarcza dokumentacji poprzez przykłady. Jeśli jesteś nowy w Go, sprawdź nasz arkusz wskazówek dla Go jako szybki przewodnik po podstawach języka.

Główne korzyści testowania w Go:

  • Wbudowana obsługa: Nie wymagane ramy zewnętrzne
  • Szybka realizacja: Domyślnie równoległe wykonanie testów
  • Prosty składnia: Minimalny kod szkieletowy
  • Bogate narzędzia: Raporty pokrycia, testy wydajnościowe i profilowanie
  • Przyjazne dla CI/CD: Łatwe integracja z automatycznymi potokami

Struktura projektu dla testów w Go

Testy w Go są umieszczone obok kodu produkcyjnego z wyraźną konwencją nazewnictwa:

myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│   ├── helper.go
│   └── helper_test.go
└── models/
    ├── user.go
    └── user_test.go

Główne konwencje:

  • Pliki testowe kończą się na _test.go
  • Testy są w tej samej paczce co kod (lub używają sufiksu _test dla testów czarnych skrzyń)
  • Każdy plik źródłowy może mieć odpowiadający mu plik testowy

Metody testowania paczki

Testowanie białej skrzynki (ta sama paczka):

package calculator

import "testing"
// Można uzyskać dostęp do nieeksportowanych funkcji i zmiennych

Testowanie czarnej skrzynki (zewnętrzna paczka):

package calculator_test

import (
    "testing"
    "myproject/calculator"
)
// Można uzyskać dostęp tylko do eksportowanych funkcji (rekomendowane dla publicznych API)

Podstawowa struktura testu

Każda funkcja testowa遵循 ten wzorzec:

package calculator

import "testing"

// Funkcja testowa musi zaczynać się od "Test"
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

Metody testing.T:

  • t.Error() / t.Errorf(): Oznacza test jako nieprawidłowy, ale kontynuuje
  • t.Fatal() / t.Fatalf(): Oznacza test jako nieprawidłowy i natychmiast zatrzymuje
  • t.Log() / t.Logf(): Logowanie wyjścia (wyświetlane tylko z flagą -v)
  • t.Skip() / t.Skipf(): Pomija test
  • t.Parallel(): Uruchamia test równolegle z innymi testami równoległymi

Testy oparte na tabelach: Go styl

Testy oparte na tabelach to idiomiczny sposób Go na testowanie wielu scenariuszy. Z ogólnymi typami w Go, możesz również tworzyć bezpieczne dla typów pomocniki testowe, które działają na różnych typach danych:

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 {
        t.Run(tt.name, func(t *testing.T) {
            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)
            }
        })
    }
}

Zalety:

  • Jedna funkcja testowa dla wielu scenariuszy
  • Łatwe dodawanie nowych przypadków testowych
  • Jasna dokumentacja oczekiwanej zachowania
  • Lepsza organizacja i utrzymanie testów

Uruchamianie testów

Podstawowe polecenia

# Uruchom testy w bieżącym katalogu
go test

# Uruchom testy z wyprowadzaniem szczegółów
go test -v

# Uruchom testy we wszystkich podkatalogach
go test ./...

# Uruchom konkretny test
go test -run TestAdd

# Uruchom testy pasujące do wzorca
go test -run TestCalculate/addition

# Uruchom testy równolegle (domyślnie GOMAXPROCS)
go test -parallel 4

# Uruchom testy z timeoutem
go test -timeout 30s

Pokrycie testowe

# Uruchom testy z pokryciem
go test -cover

# Wygeneruj profil pokrycia
go test -coverprofile=coverage.out

# Wyświetl pokrycie w przeglądarce
go tool cover -html=coverage.out

# Wyświetl pokrycie według funkcji
go tool cover -func=coverage.out

# Ustaw tryb pokrycia (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out

Przydatne flagi

  • -short: Uruchom testy oznaczone if testing.Short()
  • -race: Włącz detektor wyścigów (wykrywa problemy z dostępem współbieżnym)
  • -cpu: Określ wartości GOMAXPROCS
  • -count n: Uruchom każdy test n razy
  • -failfast: Zatrzymaj po pierwszym niepowodzeniu testu

Pomocniki testowe i konfiguracja

Funkcje pomocnicze

Oznacz funkcje pomocnicze t.Helper() w celu poprawienia raportowania błędów:

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // Ta linia jest raportowana jako wywołująca
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMath(t *testing.T) {
    result := Add(2, 3)
    assertEqual(t, result, 5) // Linia błędu wskazuje tutaj
}

Konfiguracja i demontaż

func TestMain(m *testing.M) {
    // Kod konfiguracji
    setup()
    
    // Uruchom testy
    code := m.Run()
    
    // Kod demontażu
    teardown()
    
    os.Exit(code)
}

Fixtury testowe

func setupTestCase(t *testing.T) func(t *testing.T) {
    t.Log("konfiguracja testu")
    return func(t *testing.T) {
        t.Log("demontaż testu")
    }
}

func TestSomething(t *testing.T) {
    teardown := setupTestCase(t)
    defer teardown(t)
    
    // Kod testowy
}

Mockowanie i iniekcja zależności

Mockowanie oparte na interfejsach

Gdy testujesz kod, który interaguje z bazami danych, użycie interfejsów ułatwia tworzenie implementacji mock. Jeśli pracujesz z PostgreSQL w Go, zobacz nasz porównanie bibliotek ORM w Go do wyboru odpowiedniej biblioteki bazy danych z dobrym testowalnością.

// Kod produkcyjny
type Database interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    db Database
}

func (s *UserService) GetUserName(id int) (string, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// Kod testowy
type MockDatabase struct {
    users map[int]*User
}

func (m *MockDatabase) GetUser(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("user not found")
}

func TestGetUserName(t *testing.T) {
    mockDB := &MockDatabase{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
        },
    }
    
    service := &UserService{db: mockDB}
    name, err := service.GetUserName(1)
    
    if err != nil {
        t.Fatalf("nieoczekiwany błąd: %v", err)
    }
    if name != "Alice" {
        t.Errorf("dostałem %s, oczekiwałem Alice", name)
    }
}

Popularne biblioteki testowe

Testify

Najpopularniejsza biblioteka testowa w Go do asercji i mockowania:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "powinny być równe")
    assert.NotNil(t, result)
}

// Przykład mockowania
type MockDB struct {
    mock.Mock
}

func (m *MockDB) GetUser(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

Inne narzędzia

  • gomock: Ramy mockowania Google z generowaniem kodu
  • httptest: Biblioteka standardowa do testowania handlerów HTTP
  • testcontainers-go: Testowanie integracji z kontenerami Docker
  • ginkgo/gomega: Ramy testowe stylu BDD

Gdy testujesz integracje z zewnętrznymi usługami, takimi jak modele AI, musisz mockować lub stubować te zależności. Na przykład, jeśli korzystasz z Ollama w Go, rozważ tworzenie wrapperów interfejsów, aby uczynić swój kod bardziej testowalnym.

Testy wydajnościowe

Go zawiera wbudowaną obsługę testów wydajnościowych:

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Uruchom testy wydajnościowe
// go test -bench=. -benchmem

Wynik pokazuje liczby iteracji na sekundę i alokacje pamięci.

Najlepsze praktyki

  1. Pisz testy oparte na tabelach: Używaj wzorca struktury wycinku do wielu przypadków testowych
  2. Używaj t.Run dla podtestów: Lepsza organizacja i możliwość wyboru wykonywania podtestów
  3. Testuj najpierw funkcje eksportowane: Skup się na zachowaniu publicznego API
  4. Zachowuj testy proste: Każdy test powinien weryfikować jedną rzecz
  5. Używaj znaczących nazw testów: Opisuj, co jest testowane i oczekiwany wynik
  6. Nie testuj szczegółów implementacji: Testuj zachowanie, nie wewnętrzne
  7. Używaj interfejsów dla zależności: Ułatwia mockowanie
  8. Dąż do wysokiego pokrycia, ale jakość nad ilością: 100% pokrycia nie oznacza braku błędów
  9. Uruchamiaj testy z flagą -race: Wykrywaj problemy współbieżne wczesnie
  10. Używaj TestMain dla kosztownej konfiguracji: Unikaj powtarzania konfiguracji w każdym teście

Przykład: Pełny zestaw testów

package user

import (
    "errors"
    "testing"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func ValidateUser(u *User) error {
    if u.Name == "" {
        return errors.New("imię nie może być puste")
    }
    if u.Email == "" {
        return errors.New("adres e-mail nie może być pusty")
    }
    return nil
}

// Plik testowy: user_test.go
func TestValidateUser(t *testing.T) {
    tests := []struct {
        name    string
        user    *User
        wantErr bool
        errMsg  string
    }{
        {
            name:    "prawidłowy użytkownik",
            user:    &User{ID: 1, Name: "Alice", Email: "alice@example.com"},
            wantErr: false,
        },
        {
            name:    "puste imię",
            user:    &User{ID: 1, Name: "", Email: "alice@example.com"},
            wantErr: true,
            errMsg:  "imię nie może być puste",
        },
        {
            name:    "pusty adres e-mail",
            user:    &User{ID: 1, Name: "Alice", Email: ""},
            wantErr: true,
            errMsg:  "adres e-mail nie może być pusty",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := ValidateUser(tt.user)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if err != nil && err.Error() != tt.errMsg {
                t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
            }
        })
    }
}

Przydatne linki

Podsumowanie

Ramy testowe Go dostarczają wszystkiego, co potrzebne do kompleksowego testowania jednostkowego z minimalnym ustawieniem. Przyjmując idiomy Go, takie jak testy oparte na tabelach, używając interfejsów do mockowania i korzystając z wbudowanych narzędzi, możesz tworzyć utrzyjmalne, niezawodne zestawy testów, które rosną wraz z Twoją bazą kodu.

Te praktyki testowania stosują się do wszystkich typów aplikacji w Go, od usług sieciowych po aplikacje CLI zbudowane z Cobra & Viper. Testowanie narzędzi CLI wymaga podobnych wzorców z dodatkowym akcentem na testowanie wejścia/wyjścia i analizy flag.

Zacznij od prostych testów, stopniowo dodawaj pokrycie i pamiętaj, że testowanie to inwestycja w jakość kodu i zaufanie do programisty. Akcent społeczności Go na testowanie ułatwia utrzymanie projektów w dłuższej perspektywie i skuteczne współpracę z członkami zespołu.