Тестирование в Go: unit-тесты, бенчмарки, моки

Тестирование в Go: unit-тесты, бенчмарки, моки

Тестирование в Go: unit-тесты, table-driven tests, бенчмарки и моки. Практические примеры и паттерны. Полное руководство для Golang разработчиков.

Тестирование в Go — это не боль, как в некоторых языках. Всё встроено в стандартную библиотеку, никаких внешних фреймворков не нужно. go test — и погнали.

Но есть нюансы. Table-driven tests, субтесты, бенчмарки, моки через интерфейсы — если не знаешь эти паттерны, будешь писать тесты как в Java образца 2005 года. Разберём всё по порядку.

Основы: первый тест

В Go тесты живут рядом с кодом в файлах *_test.go. Функция теста начинается с Test и принимает *testing.T.

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if result != 5 {
        t.Errorf("Divide(10, 2) = %d; want 5", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("expected error for division by zero")
    }
}

Запуск:

go test              # запустить тесты в текущем пакете
go test -v           # подробный вывод
go test ./...        # все пакеты рекурсивно
go test -run TestAdd # только тесты с "TestAdd" в имени

t.Error vs t.Fatal

  • t.Error() / t.Errorf() — отмечает тест как проваленный, но продолжает выполнение
  • t.Fatal() / t.Fatalf() — отмечает тест как проваленный и сразу останавливает

Используй t.Fatal когда дальнейшее выполнение теста не имеет смысла (например, если не удалось создать тестовые данные).

Table-driven tests: паттерн №1 в Go

Писать отдельную функцию на каждый кейс — это Java-стайл. В Go принято использовать table-driven tests.

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"mixed numbers", -2, 3, 1},
        {"zeros", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Преимущества:

  • Один тест покрывает много кейсов
  • Легко добавить новый кейс — просто строка в таблице
  • t.Run создаёт субтест с именем — видно какой кейс упал
  • Можно запустить конкретный субтест: go test -run TestAdd/positive

Субтесты для группировки

Субтесты можно вкладывать для логической группировки:

func TestDivide(t *testing.T) {
    t.Run("valid cases", func(t *testing.T) {
        tests := []struct {
            a, b, expected int
        }{
            {10, 2, 5},
            {9, 3, 3},
            {100, 10, 10},
        }
        for _, tt := range tests {
            result, err := Divide(tt.a, tt.b)
            if err != nil {
                t.Errorf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        }
    })

    t.Run("error cases", func(t *testing.T) {
        _, err := Divide(10, 0)
        if err == nil {
            t.Error("expected error for division by zero")
        }
    })
}

Тестирование HTTP-хендлеров

Для тестирования HTTP используй httptest — ещё один подарок стандартной библиотеки.

// handlers.go
package api

import (
    "encoding/json"
    "net/http"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    user := User{ID: 1, Name: "John"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}
// handlers_test.go
package api

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestGetUserHandler(t *testing.T) {
    // Создаём фейковый запрос
    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    
    // Создаём ResponseRecorder для записи ответа
    rr := httptest.NewRecorder()

    // Вызываем хендлер
    GetUserHandler(rr, req)

    // Проверяем статус
    if rr.Code != http.StatusOK {
        t.Errorf("status = %d; want %d", rr.Code, http.StatusOK)
    }

    // Проверяем Content-Type
    contentType := rr.Header().Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; want application/json", contentType)
    }

    // Проверяем тело ответа
    var user User
    if err := json.Unmarshal(rr.Body.Bytes(), &user); err != nil {
        t.Fatalf("failed to parse response: %v", err)
    }

    if user.ID != 1 || user.Name != "John" {
        t.Errorf("user = %+v; want {ID:1 Name:John}", user)
    }
}

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

    HealthHandler(rr, req)

    if rr.Code != http.StatusOK {
        t.Errorf("status = %d; want %d", rr.Code, http.StatusOK)
    }

    if rr.Body.String() != "OK" {
        t.Errorf("body = %s; want OK", rr.Body.String())
    }
}

Тестирование с роутером

Если используешь роутер (chi, gorilla/mux, gin), тестируй через httptest.Server:

func TestAPIRoutes(t *testing.T) {
    // Создаём роутер с хендлерами
    mux := http.NewServeMux()
    mux.HandleFunc("/user", GetUserHandler)
    mux.HandleFunc("/health", HealthHandler)

    // Создаём тестовый сервер
    server := httptest.NewServer(mux)
    defer server.Close()

    // Делаем реальный HTTP-запрос
    resp, err := http.Get(server.URL + "/health")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
    }
}

Моки через интерфейсы

В Go не нужны мок-фреймворки типа Mockito. Используй интерфейсы — это идиоматично и просто.

// repository.go
package user

type User struct {
    ID    int
    Name  string
    Email string
}

// Интерфейс для репозитория
type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(user *User) error
}

// Сервис зависит от интерфейса, не от конкретной реализации
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.GetByID(id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    return user, nil
}

func (s *UserService) CreateUser(name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(user); err != nil {
        return nil, fmt.Errorf("failed to save user: %w", err)
    }
    return user, nil
}
// repository_test.go
package user

import (
    "errors"
    "testing"
)

// Мок репозитория — просто структура, реализующая интерфейс
type mockUserRepository struct {
    users map[int]*User
    err   error // для симуляции ошибок
}

func newMockRepo() *mockUserRepository {
    return &mockUserRepository{
        users: make(map[int]*User),
    }
}

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

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    // Симулируем автоинкремент ID
    user.ID = len(m.users) + 1
    m.users[user.ID] = user
    return nil
}

// Тесты с моком
func TestUserService_GetUser(t *testing.T) {
    repo := newMockRepo()
    repo.users[1] = &User{ID: 1, Name: "John", Email: "john@example.com"}
    
    service := NewUserService(repo)

    user, err := service.GetUser(1)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if user.Name != "John" {
        t.Errorf("user.Name = %s; want John", user.Name)
    }
}

func TestUserService_GetUser_NotFound(t *testing.T) {
    repo := newMockRepo()
    service := NewUserService(repo)

    _, err := service.GetUser(999)
    if err == nil {
        t.Error("expected error for non-existent user")
    }
}

func TestUserService_GetUser_RepoError(t *testing.T) {
    repo := newMockRepo()
    repo.err = errors.New("database connection failed")
    
    service := NewUserService(repo)

    _, err := service.GetUser(1)
    if err == nil {
        t.Error("expected error when repo fails")
    }
}

func TestUserService_CreateUser(t *testing.T) {
    repo := newMockRepo()
    service := NewUserService(repo)

    user, err := service.CreateUser("Alice", "alice@example.com")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    if user.ID == 0 {
        t.Error("user.ID should be set after save")
    }
    if user.Name != "Alice" {
        t.Errorf("user.Name = %s; want Alice", user.Name)
    }
}

Принцип: Зависимости передаются через интерфейсы → в тестах подставляем моки → никаких глобальных переменных и магии.

Кстати, если хочешь увидеть примеры плохого кода без тестов — загляни в статью про антипаттерны в Go . Там есть код, который невозможно протестировать.

Бенчмарки: измеряем производительность

Бенчмарки в Go — функции с Benchmark в имени и *testing.B параметром.

// benchmark_test.go
package math

import "testing"

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

func BenchmarkDivide(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Divide(100, 5)
    }
}

Запуск:

go test -bench=.                    # все бенчмарки
go test -bench=BenchmarkAdd         # конкретный бенчмарк
go test -bench=. -benchmem          # + статистика по памяти
go test -bench=. -benchtime=5s      # 5 секунд на бенчмарк
go test -bench=. -count=5           # 5 запусков для статистики

Вывод:

BenchmarkAdd-8          1000000000      0.2900 ns/op    0 B/op    0 allocs/op
BenchmarkDivide-8       847282562       1.418 ns/op     0 B/op    0 allocs/op
  • BenchmarkAdd-8 — имя и количество ядер (GOMAXPROCS)
  • 1000000000 — сколько раз выполнился цикл (b.N)
  • 0.2900 ns/op — наносекунд на операцию
  • 0 B/op — байт памяти на операцию
  • 0 allocs/op — аллокаций на операцию

Сравнение реализаций

Бенчмарки отлично подходят для сравнения разных реализаций:

// Конкатенация строк: три способа
func ConcatPlus(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s
    }
    return result
}

func ConcatBuilder(strs []string) string {
    var builder strings.Builder
    for _, s := range strs {
        builder.WriteString(s)
    }
    return builder.String()
}

func ConcatJoin(strs []string) string {
    return strings.Join(strs, "")
}
// benchmark_test.go
var testStrings = []string{"hello", "world", "foo", "bar", "baz"}

func BenchmarkConcatPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatPlus(testStrings)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatBuilder(testStrings)
    }
}

func BenchmarkConcatJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatJoin(testStrings)
    }
}
BenchmarkConcatPlus-8       5765996     207.3 ns/op    56 B/op    4 allocs/op
BenchmarkConcatBuilder-8   12491854      96.01 ns/op   64 B/op    2 allocs/op
BenchmarkConcatJoin-8      21663990      55.36 ns/op   32 B/op    1 allocs/op

Видно, что strings.Join в 4 раза быстрее наивной конкатенации через +.

Бенчмарки с разными входными данными

func BenchmarkConcatJoin(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}
    
    for _, size := range sizes {
        strs := make([]string, size)
        for i := range strs {
            strs[i] = "x"
        }
        
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                ConcatJoin(strs)
            }
        })
    }
}
BenchmarkConcatJoin/size=10-8       15462980      77.54 ns/op
BenchmarkConcatJoin/size=100-8       2994873     400.7 ns/op
BenchmarkConcatJoin/size=1000-8       364911    3287 ns/op
BenchmarkConcatJoin/size=10000-8       38913   30823 ns/op

Подробнее про оптимизацию производительности и когда использовать channels vs mutex — в статье про конкурентность в Go .

Покрытие кода (Coverage)

go test -cover                           # процент покрытия
go test -coverprofile=coverage.out       # сохранить в файл
go tool cover -html=coverage.out         # открыть в браузере
go tool cover -func=coverage.out         # покрытие по функциям

Вывод -func:

math.go:5:    Add         100.0%
math.go:9:    Divide      100.0%
total:        (statements) 100.0%

Coverage в CI

# .gitlab-ci.yml
test:
  script:
    - go test -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | grep total
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

TestMain: setup и teardown

Если нужен глобальный setup/teardown для всех тестов пакета:

func TestMain(m *testing.M) {
    // Setup
    fmt.Println("Setting up tests...")
    db := setupTestDB()

    // Запуск тестов
    code := m.Run()

    // Teardown
    fmt.Println("Cleaning up...")
    db.Close()

    os.Exit(code)
}

Тестирование с временными файлами

func TestFileProcessing(t *testing.T) {
    // t.TempDir() автоматически удаляется после теста
    tmpDir := t.TempDir()
    
    filePath := filepath.Join(tmpDir, "test.txt")
    if err := os.WriteFile(filePath, []byte("test content"), 0644); err != nil {
        t.Fatal(err)
    }

    // Тестируем функцию, работающую с файлом
    result, err := ProcessFile(filePath)
    if err != nil {
        t.Fatalf("ProcessFile failed: %v", err)
    }

    // Проверяем результат
    if result != "expected" {
        t.Errorf("result = %s; want expected", result)
    }
}

Параллельные тесты

func TestParallel(t *testing.T) {
    tests := []struct {
        name  string
        input int
    }{
        {"case1", 1},
        {"case2", 2},
        {"case3", 3},
    }

    for _, tt := range tests {
        tt := tt // важно! захват переменной для горутины
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // этот субтест может выполняться параллельно
            
            // ... тестовая логика
            time.Sleep(100 * time.Millisecond)
        })
    }
}

Важно: tt := tt — это Go quirk. Без этого все горутины будут использовать последнее значение tt из цикла.

Запуск с контролем параллелизма:

go test -parallel 4  # максимум 4 параллельных теста

Полезные практики

1. Именование тестов

// Хорошо: понятно что тестируем и какой кейс
func TestUserService_GetUser_ReturnsUser(t *testing.T) {}
func TestUserService_GetUser_NotFound(t *testing.T) {}
func TestUserService_GetUser_DatabaseError(t *testing.T) {}

// Плохо: непонятно что тестируем
func TestGetUser(t *testing.T) {}
func TestGetUser2(t *testing.T) {}
func TestError(t *testing.T) {}

2. Используй testify для assertions (опционально)

import "github.com/stretchr/testify/assert"

func TestAdd(t *testing.T) {
    assert.Equal(t, 5, Add(2, 3))
    assert.NotNil(t, someValue)
    assert.Contains(t, "hello world", "world")
}

3. Golden files для сложных выводов

func TestGenerateReport(t *testing.T) {
    result := GenerateReport(testData)
    
    goldenFile := "testdata/report.golden"
    
    if *update {
        os.WriteFile(goldenFile, []byte(result), 0644)
    }
    
    expected, _ := os.ReadFile(goldenFile)
    if result != string(expected) {
        t.Errorf("result differs from golden file")
    }
}

4. Тестируй публичный API, не внутренности

// Хорошо: тестируем поведение
func TestCalculator_Add(t *testing.T) {
    calc := NewCalculator()
    result := calc.Add(2, 3)
    assert.Equal(t, 5, result)
}

// Плохо: тестируем приватные методы
func TestCalculator_internalAdd(t *testing.T) {
    // Тест сломается при любом рефакторинге
}

Заключение

Тестирование в Go — это просто:

  • *_test.go файлы, функции Test*, go test
  • Table-driven tests для множества кейсов
  • Моки через интерфейсы, без фреймворков
  • Бенчмарки для измерения производительности
  • Coverage из коробки

Главные принципы:

  1. Тестируй поведение, не реализацию
  2. Используй table-driven tests
  3. Моки через интерфейсы
  4. Бенчмаркай перед оптимизацией

Если хочешь автоматизировать написание тестов — Cursor AI неплохо справляется с генерацией table-driven tests по примеру.


Есть вопросы по тестированию в Go? Пиши в комментарии — разберём конкретные кейсы.