Тестирование - встроено в язык

Go имеет встроенную поддержку тестирования. Никаких внешних фреймворков не нужно - всё есть в стандартной библиотеке.

Основы тестирования

Первый тест

// math.go
package math

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

func Multiply(a, b int) int {
    return a * b
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
    }
}

func TestMultiply(t *testing.T) {
    result := Multiply(3, 4)
    expected := 12

    if result != expected {
        t.Errorf("Multiply(3, 4) = %d; expected %d", result, expected)
    }
}
- Тестовые файлы заканчиваются на _test.go - Тестовые функции начинаются с Test - Принимают параметр *testing.T

Запуск тестов

# Все тесты в пакете
go test

# С подробным выводом
go test -v

# Конкретный тест
go test -run TestAdd

# Все тесты в проекте
go test ./...

# С покрытием
go test -cover

Table-driven тесты

Самый популярный паттерн в Go:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"mixed numbers", -5, 10, 5},
        {"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; expected %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Более сложный пример

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        dividend  float64
        divisor   float64
        want      float64
        wantError bool
        errorMsg  string
    }{
        {
            name:     "positive division",
            dividend: 10,
            divisor:  2,
            want:     5,
        },
        {
            name:      "division by zero",
            dividend:  10,
            divisor:   0,
            wantError: true,
            errorMsg:  "division by zero",
        },
        {
            name:     "negative result",
            dividend: -10,
            divisor:  2,
            want: -5,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.dividend, tt.divisor)

            if tt.wantError {
                if err == nil {
                    t.Errorf("expected error, got nil")
                    return
                }
                if err.Error() != tt.errorMsg {
                    t.Errorf("error = %v; want %v", err, tt.errorMsg)
                }
                return
            }

            if err != nil {
                t.Errorf("unexpected error: %v", err)
                return
            }

            if got != tt.want {
                t.Errorf("got %f; want %f", got, tt.want)
            }
        })
    }
}

Helper функции

func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper() // помечает функцию как helper

    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

func assertError(t *testing.T, err error, want string) {
    t.Helper()

    if err == nil {
        t.Fatal("expected error, got nil")
    }

    if err.Error() != want {
        t.Errorf("error = %v; want %v", err, want)
    }
}

func TestWithHelpers(t *testing.T) {
    result, err := Calculate(10, 2)

    assertError(t, err, nil)
    assertEqual(t, result, 5)
}

Тестирование HTTP handlers

Если ещё не знаком с http.HandlerFunc - посмотри урок HTTP сервер из коробки.

func TestHealthCheckHandler(t *testing.T) {
    // Создаем запрос
    req, err := http.NewRequest("GET", "/health", nil)
    if err != nil {
        t.Fatal(err)
    }

    // Создаем ResponseRecorder
    rr := httptest.NewRecorder()

    // Вызываем handler
    handler := http.HandlerFunc(HealthCheckHandler)
    handler.ServeHTTP(rr, req)

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

    // Проверяем тело ответа
    expected := `{"status":"ok"}`
    if rr.Body.String() != expected {
        t.Errorf("handler returned unexpected body: got %v want %v",
            rr.Body.String(), expected)
    }
}

// Тестирование с разными методами и данными
func TestCreateUserHandler(t *testing.T) {
    tests := []struct {
        name       string
        method     string
        body       string
        wantStatus int
        wantBody   string
    }{
        {
            name:       "valid request",
            method:     "POST",
            body:       `{"name":"Alice","email":"alice@example.com"}`,
            wantStatus: http.StatusCreated,
            wantBody:   `{"id":1,"name":"Alice","email":"alice@example.com"}`,
        },
        {
            name:       "invalid method",
            method:     "GET",
            body:       "",
            wantStatus: http.StatusMethodNotAllowed,
            wantBody:   "Method not allowed\n",
        },
        {
            name:       "invalid json",
            method:     "POST",
            body:       `{"name":"Alice"`,
            wantStatus: http.StatusBadRequest,
            wantBody:   "Invalid JSON\n",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(tt.method, "/users", strings.NewReader(tt.body))
            req.Header.Set("Content-Type", "application/json")

            rr := httptest.NewRecorder()
            handler := http.HandlerFunc(CreateUserHandler)

            handler.ServeHTTP(rr, req)

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

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

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

// Интерфейс для тестирования
type UserRepository interface {
    Create(user *User) error
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Mock реализация
type MockUserRepository struct {
    CreateFunc      func(user *User) error
    FindByIDFunc    func(id int) (*User, error)
    FindByEmailFunc func(email string) (*User, error)
}

func (m *MockUserRepository) Create(user *User) error {
    if m.CreateFunc != nil {
        return m.CreateFunc(user)
    }
    return nil
}

func (m *MockUserRepository) FindByID(id int) (*User, error) {
    if m.FindByIDFunc != nil {
        return m.FindByIDFunc(id)
    }
    return nil, nil
}

func (m *MockUserRepository) FindByEmail(email string) (*User, error) {
    if m.FindByEmailFunc != nil {
        return m.FindByEmailFunc(email)
    }
    return nil, nil
}

// Тестирование с моком
func TestUserService_Create(t *testing.T) {
    mockRepo := &MockUserRepository{
        CreateFunc: func(user *User) error {
            if user.Email == "exists@example.com" {
                return ErrEmailExists
            }
            user.ID = 123
            return nil
        },
    }

    service := NewUserService(mockRepo)

    t.Run("successful creation", func(t *testing.T) {
        user := &User{
            Name:  "Alice",
            Email: "alice@example.com",
        }

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

        if user.ID != 123 {
            t.Errorf("user.ID = %d; want 123", user.ID)
        }
    })

    t.Run("email exists", func(t *testing.T) {
        user := &User{
            Name:  "Bob",
            Email: "exists@example.com",
        }

        err := service.Create(user)
        if err != ErrEmailExists {
            t.Errorf("error = %v; want ErrEmailExists", err)
        }
    })
}

Бенчмарки

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

func BenchmarkStringConcat(b *testing.B) {
    b.Run("with +", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = "Hello" + " " + "World"
        }
    })

    b.Run("with fmt.Sprintf", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = fmt.Sprintf("%s %s", "Hello", "World")
        }
    })

    b.Run("with strings.Builder", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            sb.WriteString("Hello")
            sb.WriteString(" ")
            sb.WriteString("World")
            _ = sb.String()
        }
    })
}

// Запуск: go test -bench=.
// Или конкретный: go test -bench=BenchmarkStringConcat

Бенчмарк с подготовкой

func BenchmarkSort(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}

    for _, size := range sizes {
        b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
            // Подготовка данных
            data := make([]int, size)
            for i := range data {
                data[i] = rand.Intn(size)
            }

            b.ResetTimer() // сброс таймера после подготовки

            for i := 0; i < b.N; i++ {
                b.StopTimer() // пауза для копирования
                copied := make([]int, len(data))
                copy(copied, data)
                b.StartTimer() // продолжаем замер

                sort.Ints(copied)
            }
        })
    }
}

Интеграционные тесты

// +build integration

package main

import (
    "database/sql"
    "testing"
)

func TestUserRepository_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    // Подключаемся к тестовой БД
    db, err := sql.Open("postgres", "postgres://test@localhost/testdb?sslmode=disable")
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // Очищаем данные
    _, err = db.Exec("TRUNCATE TABLE users")
    if err != nil {
        t.Fatal(err)
    }

    repo := NewUserRepository(db)

    t.Run("Create and Find", func(t *testing.T) {
        user := &User{
            Name:  "Test User",
            Email: "test@example.com",
        }

        // Create
        err := repo.Create(user)
        if err != nil {
            t.Fatalf("Create() error = %v", err)
        }

        if user.ID == 0 {
            t.Error("expected user.ID to be set")
        }

        // Find
        found, err := repo.FindByID(user.ID)
        if err != nil {
            t.Fatalf("FindByID() error = %v", err)
        }

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

// Запуск: go test -tags=integration

Test fixtures и cleanup

func setupTest(t *testing.T) (*sql.DB, func()) {
    t.Helper()

    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatal(err)
    }

    // Создаем схему
    _, err = db.Exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
    `)
    if err != nil {
        t.Fatal(err)
    }

    // Cleanup функция
    cleanup := func() {
        db.Close()
    }

    return db, cleanup
}

func TestWithFixture(t *testing.T) {
    db, cleanup := setupTest(t)
    defer cleanup()

    // Используем db для тестов
    repo := NewUserRepository(db)

    // тесты...
}

// Или с t.Cleanup (Go 1.14+)
func TestWithCleanup(t *testing.T) {
    db := setupDB(t)

    t.Cleanup(func() {
        db.Close()
    })

    // тесты...
}

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

func TestParallel(t *testing.T) {
    // Эти тесты будут выполняться параллельно
    t.Run("test1", func(t *testing.T) {
        t.Parallel()
        time.Sleep(1 * time.Second)
        // тест...
    })

    t.Run("test2", func(t *testing.T) {
        t.Parallel()
        time.Sleep(1 * time.Second)
        // тест...
    })

    t.Run("test3", func(t *testing.T) {
        t.Parallel()
        time.Sleep(1 * time.Second)
        // тест...
    })
}

Golden files

func TestGenerateReport(t *testing.T) {
    report := GenerateReport(testData)

    golden := filepath.Join("testdata", "report.golden")

    if *update {
        // Обновляем golden файл
        err := os.WriteFile(golden, []byte(report), 0644)
        if err != nil {
            t.Fatal(err)
        }
    }

    expected, err := os.ReadFile(golden)
    if err != nil {
        t.Fatal(err)
    }

    if report != string(expected) {
        t.Errorf("report does not match golden file")
        t.Logf("got:\n%s", report)
        t.Logf("want:\n%s", expected)
    }
}

// Запуск с обновлением: go test -update

Best practices

<ComparisonTable data={{ headers: ["Практика", "Описание", "Пример"], rows: [ ["Имена тестов", "Описательные имена", "Test_UserService_Create_WithExistingEmail"], ["Table-driven", "Для множества случаев", "tests := []struct{...}"], ["t.Helper()", "Для вспомогательных функций", "Показывает правильную строку ошибки"], ["t.Parallel()", "Для независимых тестов", "Ускоряет выполнение"], ["Моки", "Через интерфейсы", "Легко тестировать зависимости"] ] }} />

Итоги

  • Тестирование встроено в Go
  • Table-driven тесты - стандарт
  • Интерфейсы облегчают мокирование
  • Бенчмарки для оптимизации
  • go test - мощный инструмент

В финальном уроке создадим полноценный проект!

Типичная ошибка

Писать тест, который зависит от времени/порядка/случайности без контроля. Он будет «флапать».

Мини-практика

Сделай табличный тест для Grade(score int) string - это функция из задания к уроку про условия: если ещё не решал его, реши там, а здесь покрой тестами.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.