Home » Как писать юнит-тесты в Go с помощью go test и пакета testing
Как писать юнит-тесты в Go с помощью go test и пакета testing

Как писать юнит-тесты в Go с помощью go test и пакета testing

Если ты серьёзно относишься к разработке и запуску сервисов в production, то тебе просто не обойтись без качественных юнит-тестов. Особенно если работаешь с Go — языком, который изначально создавался с мыслью о надёжности и простоте. Сегодня разберём, как правильно писать unit-тесты в Go с помощью встроенного пакета testing и инструмента go test. Это не теория ради теории — это must-have навык для любого, кто деплоит код на серверы и не хочет просыпаться ночью от аварийных уведомлений.

Зачем это нужно? Всё просто: тесты — это твоя страховка от багов в production. Они помогают быстро находить проблемы при рефакторинге, дают уверенность в работе кода и существенно экономят время на отладку. Плюс, если используешь CI/CD пайплайны на своих серверах, хорошие тесты станут основой для автоматизации деплоя.

Как это работает: основы go test

Go подходит к тестированию максимально прагматично. Никаких внешних зависимостей — всё встроено в язык. Пакет testing предоставляет простой API для написания тестов, а команда go test умеет их находить и запускать.

Основные принципы работы:

  • Тестовые файлы должны заканчиваться на _test.go
  • Функции тестов начинаются с Test и принимают параметр *testing.T
  • Для бенчмарков используй префикс Benchmark и параметр *testing.B
  • Тесты живут в том же пакете, что и код

Вот простейший пример:

// math.go
package main

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

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

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(4, 5)
    expected := 20
    
    if result != expected {
        t.Errorf("Multiply(4, 5) = %d; expected %d", result, expected)
    }
}

Пошаговая настройка и запуск

Начнём с самого простого. Создаём проект и пишем первый тест:

# Создаём директорию проекта
mkdir go-testing-example
cd go-testing-example

# Инициализируем модуль
go mod init testing-example

# Создаём файл с кодом
touch calculator.go

# Создаём файл с тестами
touch calculator_test.go

Теперь добавляем код в calculator.go:

package main

import (
    "errors"
    "math"
)

type Calculator struct{}

func (c *Calculator) Add(a, b float64) float64 {
    return a + b
}

func (c *Calculator) Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func (c *Calculator) Sqrt(x float64) (float64, error) {
    if x < 0 {
        return 0, errors.New("negative number")
    }
    return math.Sqrt(x), nil
}

И тесты в calculator_test.go:

package main

import (
    "testing"
)

func TestCalculator_Add(t *testing.T) {
    calc := &Calculator{}
    
    tests := []struct {
        name     string
        a, b     float64
        expected float64
    }{
        {"positive numbers", 2.5, 3.5, 6.0},
        {"negative numbers", -1.0, -2.0, -3.0},
        {"zero", 0, 5.0, 5.0},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := calc.Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%f, %f) = %f; expected %f", 
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func TestCalculator_Divide(t *testing.T) {
    calc := &Calculator{}
    
    // Тест успешного деления
    result, err := calc.Divide(10.0, 2.0)
    if err != nil {
        t.Errorf("Divide(10.0, 2.0) returned error: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Divide(10.0, 2.0) = %f; expected 5.0", result)
    }
    
    // Тест деления на ноль
    _, err = calc.Divide(10.0, 0.0)
    if err == nil {
        t.Error("Divide(10.0, 0.0) should return error")
    }
}

func TestCalculator_Sqrt(t *testing.T) {
    calc := &Calculator{}
    
    // Позитивный тест
    result, err := calc.Sqrt(9.0)
    if err != nil {
        t.Errorf("Sqrt(9.0) returned error: %v", err)
    }
    if result != 3.0 {
        t.Errorf("Sqrt(9.0) = %f; expected 3.0", result)
    }
    
    // Негативный тест
    _, err = calc.Sqrt(-1.0)
    if err == nil {
        t.Error("Sqrt(-1.0) should return error")
    }
}

Основные команды go test

Вот полный набор команд, которые понадобятся в работе:

# Запуск всех тестов в текущем пакете
go test

# Запуск тестов с детальным выводом
go test -v

# Запуск тестов в конкретном файле
go test -v calculator_test.go calculator.go

# Запуск конкретного теста
go test -v -run TestCalculator_Add

# Запуск тестов с покрытием кода
go test -cover

# Генерация HTML отчёта по покрытию
go test -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

# Запуск тестов во всех подпакетах
go test ./...

# Запуск тестов в параллельном режиме
go test -parallel 4

# Запуск с таймаутом
go test -timeout 30s

# Бенчмарки
go test -bench=.

# Профилирование CPU
go test -cpuprofile=cpu.prof -bench=.

# Профилирование памяти
go test -memprofile=mem.prof -bench=.

Продвинутые техники тестирования

Когда базовые навыки освоены, можно переходить к более сложным вещам. Разберём несколько полезных паттернов:

Table-driven тесты

Это классика Go. Позволяет тестировать множество сценариев в одной функции:

func TestStringValidator(t *testing.T) {
    tests := []struct {
        name     string
        input    string
        expected bool
    }{
        {"valid email", "user@example.com", true},
        {"invalid email", "invalid-email", false},
        {"empty string", "", false},
        {"too long", strings.Repeat("a", 1000), false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := IsValidEmail(tt.input)
            if result != tt.expected {
                t.Errorf("IsValidEmail(%q) = %v; expected %v", 
                    tt.input, result, tt.expected)
            }
        })
    }
}

Мокирование и зависимости

Для работы с внешними зависимостями используй интерфейсы:

// Интерфейс для базы данных
type UserStore interface {
    GetUser(id int) (*User, error)
    SaveUser(*User) error
}

// Реальная реализация
type PostgresStore struct {
    db *sql.DB
}

func (p *PostgresStore) GetUser(id int) (*User, error) {
    // Реальная работа с базой
    return nil, nil
}

// Мок для тестов
type MockUserStore struct {
    users map[int]*User
}

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

func (m *MockUserStore) SaveUser(user *User) error {
    m.users[user.ID] = user
    return nil
}

// Тест с моком
func TestUserService_GetUser(t *testing.T) {
    mockStore := &MockUserStore{
        users: map[int]*User{
            1: {ID: 1, Name: "John"},
        },
    }
    
    service := &UserService{store: mockStore}
    
    user, err := service.GetUser(1)
    if err != nil {
        t.Errorf("GetUser(1) returned error: %v", err)
    }
    
    if user.Name != "John" {
        t.Errorf("GetUser(1) returned name %s; expected John", user.Name)
    }
}

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

Для веб-сервисов Go предоставляет удобные инструменты:

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest("GET", "/health", nil)
    rr := httptest.NewRecorder()
    
    handler := http.HandlerFunc(HealthHandler)
    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 BenchmarkStringConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var result string
        for j := 0; j < 1000; j++ {
            result += "a"
        }
    }
}

func BenchmarkStringBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var builder strings.Builder
        for j := 0; j < 1000; j++ {
            builder.WriteString("a")
        }
        _ = builder.String()
    }
}

func BenchmarkParallelWork(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            // Какая-то CPU-интенсивная работа
            for i := 0; i < 10000; i++ {
                _ = i * i
            }
        }
    })
}

Запуск бенчмарков:

# Запуск всех бенчмарков
go test -bench=.

# Запуск конкретного бенчмарка
go test -bench=BenchmarkStringConcat

# Бенчмарк с профилированием памяти
go test -bench=. -memprofile=mem.prof

# Сравнение производительности
go test -bench=. -count=5 | tee bench.txt

Сравнение с другими решениями

Решение Плюсы Минусы Когда использовать
Стандартный testing Встроен в язык, простой, быстрый Базовая функциональность Для большинства случаев
Testify Удобные assert'ы, моки Внешняя зависимость Для сложных тестов
Ginkgo + Gomega BDD-стиль, много возможностей Сложность, overhead Для больших проектов
GoConvey Веб-интерфейс, живые тесты Избыточность для простых случаев Для разработки с интерфейсом

Интеграция с CI/CD

Если деплоишь код на VPS или выделенный сервер, то автоматизация тестирования — это святое. Вот пример конфигурации для различных CI систем:

# .github/workflows/test.yml (GitHub Actions)
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Go
      uses: actions/setup-go@v2
      with:
        go-version: 1.21
    - name: Run tests
      run: |
        go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
    - name: Upload coverage
      uses: codecov/codecov-action@v1
      with:
        file: ./coverage.txt

# .gitlab-ci.yml
test:
  image: golang:1.21
  script:
    - go mod download
    - go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
    - go tool cover -func=coverage.txt
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

Нестандартные способы использования

Пакет testing можно использовать не только для unit-тестов. Вот несколько интересных приёмов:

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

func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    
    // Запуск внешних сервисов, тестирование API
    db := setupTestDB(t)
    defer teardownTestDB(t, db)
    
    // Тесты с реальной базой данных
}

Фаззинг (Go 1.18+)

func FuzzParseURL(f *testing.F) {
    f.Add("http://example.com")
    f.Add("https://example.com/path")
    f.Add("ftp://example.com")
    
    f.Fuzz(func(t *testing.T, url string) {
        parsed, err := ParseURL(url)
        if err != nil {
            return // Ошибка разбора - это нормально
        }
        
        // Проверяем инварианты
        if parsed.Host == "" && parsed.Scheme != "" {
            t.Errorf("URL with scheme should have host: %s", url)
        }
    })
}

Генерация тестовых данных

func TestMain(m *testing.M) {
    // Подготовка к тестам
    setup()
    
    // Запуск тестов
    code := m.Run()
    
    // Очистка после тестов
    cleanup()
    
    os.Exit(code)
}

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

  • Testify — удобные assertions и моки
  • GoMock — генерация моков по интерфейсам
  • sqlmock — мокирование SQL-запросов
  • httpmock — мокирование HTTP-запросов
  • Mockery — альтернативный генератор моков

Скрипты для автоматизации

Создай Makefile для упрощения работы с тестами:

# Makefile
.PHONY: test test-verbose test-race test-cover test-bench clean

test:
	go test ./...

test-verbose:
	go test -v ./...

test-race:
	go test -race ./...

test-cover:
	go test -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html
	@echo "Coverage report generated: coverage.html"

test-bench:
	go test -bench=. -benchmem ./...

test-integration:
	go test -tags=integration ./...

clean:
	rm -f coverage.out coverage.html *.prof

# Запуск с различными настройками
test-ci:
	go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
	go tool cover -func=coverage.txt

Статистика и мониторинг тестов

Для серьёзных проектов важно следить за метриками тестов:

# Скрипт для анализа тестов
#!/bin/bash

echo "=== Test Coverage Report ==="
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1

echo "=== Test Performance ==="
go test -bench=. -benchtime=5s ./... | grep -E "(Benchmark|PASS|FAIL)"

echo "=== Test Count ==="
find . -name "*_test.go" -exec grep -l "func Test" {} \; | wc -l

echo "=== Failed Tests History ==="
go test -json ./... | jq -r 'select(.Action == "fail") | .Test'

Заключение и рекомендации

Тестирование в Go — это не просто good practice, а необходимость для любого серьёзного проекта. Встроенный пакет testing покрывает 90% потребностей, а для остальных 10% есть отличные сторонние решения.

Основные рекомендации:

  • Начинай с простых unit-тестов на критичную логику
  • Используй table-driven тесты для проверки множества сценариев
  • Не забывай про тесты ошибок — они часто выявляют проблемы
  • Интегрируй тесты в CI/CD pipeline с самого начала
  • Стремись к покрытию 70-80% — больше часто не оправдано
  • Бенчмарки обязательны для performance-критичного кода

Помни: хорошие тесты — это инвестиция в будущее. Они сэкономят тебе массу времени на отладку и дадут уверенность при деплое на production серверы. Начни с малого, но начни сегодня — твоё будущее "я" скажет спасибо.


В этой статье собрана информация и материалы из различных интернет-источников. Мы признаем и ценим работу всех оригинальных авторов, издателей и веб-сайтов. Несмотря на то, что были приложены все усилия для надлежащего указания исходного материала, любая непреднамеренная оплошность или упущение не являются нарушением авторских прав. Все упомянутые товарные знаки, логотипы и изображения являются собственностью соответствующих владельцев. Если вы считаете, что какой-либо контент, использованный в этой статье, нарушает ваши авторские права, немедленно свяжитесь с нами для рассмотрения и принятия оперативных мер.

Данная статья предназначена исключительно для ознакомительных и образовательных целей и не ущемляет права правообладателей. Если какой-либо материал, защищенный авторским правом, был использован без должного упоминания или с нарушением законов об авторском праве, это непреднамеренно, и мы исправим это незамедлительно после уведомления. Обратите внимание, что переиздание, распространение или воспроизведение части или всего содержимого в любой форме запрещено без письменного разрешения автора и владельца веб-сайта. Для получения разрешений или дополнительных запросов, пожалуйста, свяжитесь с нами.

Leave a reply

Your email address will not be published. Required fields are marked