Перейти к основному содержимому

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

Введение в тестирование

Go имеет встроенную поддержку тестирования через пакет testing. Это делает написание тестов простым и стандартизированным процессом.

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

Что такое юнит-тесты?

Юнит-тесты (unit tests) — это автоматические тесты, которые проверяют отдельные маленькие части кода (обычно одну функцию или метод) в изоляции от остальной программы.

Главная идея

  • Юнит = минимальная единица кода, которую можно протестировать (функция, метод структуры).
  • Тест проверяет: при определённых входных данных функция выдаёт ожидаемый результат и ведёт себя правильно.
  • Всё остальное (база данных, сеть, файлы) подменяется моками или фиктивными данными, чтобы тест был быстрым и предсказуемым.

Структура тестового файла

Тесты в Go размещаются в файлах с суффиксом _test.go:

// Файл: math.go
package math

// Add складывает два числа
func Add(a, b int) int {
return a + b
}

// Subtract вычитает второе число из первого
func Subtract(a, b int) int {
return a - b
}

// Multiply умножает два числа
func Multiply(a, b int) int {
return a * b
}

// Divide делит первое число на второе
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("деление на ноль")
}
return a / b, nil
}
// Файл: math_test.go
package math

import (
"testing"
)

// Тестовая функция начинается с Test
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5

if result != expected {
t.Errorf("Add(2, 3) = %d; ожидается %d", result, expected)
}
}

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

if result != expected {
t.Errorf("Subtract(5, 3) = %d; ожидается %d", result, expected)
}
}

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

if result != expected {
t.Errorf("Multiply(3, 4) = %d; ожидается %d", result, expected)
}
}

func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Errorf("Неожиданная ошибка: %v", err)
}

expected := 5.0
if result != expected {
t.Errorf("Divide(10, 2) = %f; ожидается %f", result, expected)
}
}

func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("Ожидалась ошибка при делении на ноль")
}
}

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

# Запустить все тесты в текущем пакете
go test

# Запустить тесты с подробным выводом
go test -v

# Запустить конкретный тест
go test -run TestAdd

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

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

# Генерировать отчет о покрытии
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Table-Driven Tests (табличные тесты)

Табличные тесты - это идиоматичный способ в Go для тестирования множества случаев:

// Файл: string_utils_test.go
package stringutils

import (
"testing"
)

func TestReverse(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "обычная строка",
input: "hello",
expected: "olleh",
},
{
name: "пустая строка",
input: "",
expected: "",
},
{
name: "строка с пробелами",
input: "hello world",
expected: "dlrow olleh",
},
{
name: "строка с Unicode",
input: "привет",
expected: "тевирп",
},
{
name: "одиночный символ",
input: "a",
expected: "a",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Reverse(tt.input)
if result != tt.expected {
t.Errorf("Reverse(%q) = %q; ожидается %q",
tt.input, result, tt.expected)
}
})
}
}

func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"пустая строка", "", true},
{"одна буква", "a", true},
{"палиндром", "racecar", true},
{"не палиндром", "hello", false},
{"палиндром с пробелами", "А роза упала на лапу Азора", true},
{"палиндром числа", "12321", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsPalindrome(tt.input)
if result != tt.expected {
t.Errorf("IsPalindrome(%q) = %v; ожидается %v",
tt.input, result, tt.expected)
}
})
}
}

Подтесты (Subtests)

Подтесты позволяют группировать связанные тесты и контролировать их выполнение:

func TestValidateUser(t *testing.T) {
t.Run("валидация username", func(t *testing.T) {
tests := []struct {
name string
username string
shouldErr bool
}{
{"корректный username", "john_doe", false},
{"слишком короткий", "ab", true},
{"слишком длинный", strings.Repeat("a", 51), true},
{"пустой", "", true},
{"с пробелами", "john doe", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUsername(tt.username)
if (err != nil) != tt.shouldErr {
t.Errorf("ValidateUsername(%q) error = %v; shouldErr = %v",
tt.username, err, tt.shouldErr)
}
})
}
})

t.Run("валидация email", func(t *testing.T) {
tests := []struct {
name string
email string
shouldErr bool
}{
{"корректный email", "test@example.com", false},
{"без @", "testexample.com", true},
{"без домена", "test@", true},
{"пустой", "", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if (err != nil) != tt.shouldErr {
t.Errorf("ValidateEmail(%q) error = %v; shouldErr = %v",
tt.email, err, tt.shouldErr)
}
})
}
})
}

// Запуск конкретного подтеста
// go test -run TestValidateUser/валидация_username

Хелперы для тестов

Что такое хелперы в тестах Go и зачем они нужны?

Хелперы (test helpers) — это вспомогательные функции, которые ты пишешь сам, чтобы сделать тесты короче, чище и удобнее для чтения. Они не влияют на логику тестируемого кода, а только помогают проверять результаты.

Зачем нужны хелперы?

  1. Уменьшают дублирование кода в тестах
    Вместо того чтобы в каждом тесте писать одно и то же:

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

    ты выносишь это в функцию assertEqual и используешь её везде.

  2. Делают тесты читаемыми
    Сравни:

    // Без хелпера — громоздко
    if user == nil {
    t.Error("expected non-nil user")
    }
    if user.Username != "john" {
    t.Errorf("got %q, want %q", user.Username, "john")
    }

    // С хелпером — красиво и понятно
    assertNotNil(t, user)
    assertEqual(t, user.Username, "john")

    Второй вариант сразу видно, что именно проверяется.

  3. t.Helper() — важная магия
    Когда ты вызываешь t.Helper() внутри хелпера, Go понимает: "это вспомогательная функция".
    При ошибке в тесте номер строки будет показан в самом тесте, а не внутри хелпера.

    Пример вывода ошибки:

    // Без t.Helper()
    testing_helpers_test.go:15: got nil; want &User{...}

    // С t.Helper()
    testing_helpers_test.go:45: assertNotNil failed // ← строка из TestUserCreation!

    Это сильно упрощает отладку: ты сразу видишь, в каком тесте проблема.

Когда стоит писать хелперы

  • Если одна и та же проверка повторяется в нескольких тестах.
  • Если хочешь использовать table-driven tests (таблицу тестов) и сделать их компактными.
  • Если проект большой и тесты становятся трудночитаемыми.

Популярные хелперы

func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}

func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}

func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Error("expected error, got nil")
}
}

С дженериками (Go 1.18+) assertEqual работает с любым сравнимым типом.

Альтернативы (для информации)

  • Библиотека testify (require, assert) — очень популярна, даёт готовые хелперы.
  • gotest.tools/assert — лёгкая альтернатива.
  • Но в небольших проектах или для обучения — свои хелперы отлично учат понимать, как работают тесты.

Примеры

// Файл: testing_helpers_test.go
package myapp

import (
"testing"
)

// assertEqual - вспомогательная функция для сравнения значений
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // Указывает, что это вспомогательная функция
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}

// assertError - проверяет наличие ошибки
func assertError(t *testing.T, err error, wantErr bool) {
t.Helper()
if (err != nil) != wantErr {
t.Errorf("error = %v; wantErr = %v", err, wantErr)
}
}

// assertNil - проверяет, что значение nil
func assertNil(t *testing.T, got interface{}) {
t.Helper()
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}

// assertNotNil - проверяет, что значение не nil
func assertNotNil(t *testing.T, got interface{}) {
t.Helper()
if got == nil {
t.Error("expected non-nil value")
}
}

// Использование хелперов
func TestUserCreation(t *testing.T) {
user := CreateUser("john", "john@example.com")

assertNotNil(t, user)
assertEqual(t, user.Username, "john")
assertEqual(t, user.Email, "john@example.com")
}

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

Моки (mocks) — это поддельные реализации интерфейсов или зависимостей, которые используются только в тестах. Они имитируют поведение реальных компонентов (например, базы данных, внешних API, файловой системы), но работают быстро, предсказуемо и без побочных эффектов.

Что такое моки и зачем они нужны?

Моки (mocks) — это поддельные реализации интерфейсов или зависимостей, которые используются только в тестах.
Они имитируют поведение реальных компонентов (например, базы данных, внешних API, файловой системы), но работают быстро, предсказуемо и без побочных эффектов.

Зачем нужны моки?

  1. Тестировать логику в изоляции
    Ты хочешь проверить, правильно ли работает твой сервис (UserService), а не база данных.
    С моками ты можешь сосредоточиться только на коде сервиса, не запуская настоящую БД.

  2. Контролировать поведение зависимостей
    Ты можешь заставить мок возвращать:

    • Успешный результат
    • Ошибку (например, "пользователь не найден")
    • Любые данные, какие нужно для теста
  3. Быстрые и надёжные тесты
    Тесты с моками работают за миллисекунды, не зависят от сети, базы данных или внешних сервисов.
    Они всегда дают одинаковый результат при одинаковых входных данных.

  4. Проверять сложные сценарии
    Например:

    • Что будет, если база данных вернёт ошибку?
    • Что будет, если пользователь с таким email уже существует? С реальной БД это сложно симулировать, а с моками — легко.

Как это работает в примере ниже

  • У тебя есть интерфейс UserRepository — это контракт для работы с пользователями.
  • Реальная реализация (например, с PostgreSQL) будет где-то в другом месте.
  • В тестах ты создаёшь MockUserRepository — структуру, которая реализует тот же интерфейс, но поведение задаёшь сам через функции (GetByIDFunc, CreateFunc и т.д.).
  • Передаёшь этот мок в UserService — и сервис думает, что работает с настоящим репозиторием.
mockRepo := &MockUserRepository{
GetByIDFunc: func(ctx context.Context, id int) (*User, error) {
return &User{ID: 1, Username: "john", Email: "john@example.com"}, nil
},
}

service := NewUserService(mockRepo) // сервис не знает, что это мок!
user, err := service.GetUser(ctx, 1)

Почему интерфейсы так важны для моков?

Потому что в Go зависимости передаются через интерфейсы.
Благодаря этому:

  • Сервис не привязан к конкретной реализации (БД, файл, память).
  • В продакшене — настоящая БД.
  • В тестах — мок. Это называется внедрение зависимостей (dependency injection) — один из ключевых принципов хорошей архитектуры.

Альтернативы мокам (для информации)

  • Тестовая база данных (в памяти, например, SQLite) — медленнее, сложнее настраивать.
  • Библиотеки для моков: testify/mock, gomock — автоматически генерируют моки, но для начала ручные моки (как в примере) — лучший способ понять суть.

Примеры

// Файл: user_service.go
package service

import (
"context"
"errors"
)

// UserRepository - интерфейс для работы с пользователями
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
}

type User struct {
ID int
Username string
Email string
}

type UserService struct {
repo UserRepository
}

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

func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, errors.New("некорректный ID")
}
return s.repo.GetByID(ctx, id)
}

func (s *UserService) CreateUser(ctx context.Context, username, email string) (*User, error) {
if username == "" {
return nil, errors.New("username обязателен")
}
if email == "" {
return nil, errors.New("email обязателен")
}

user := &User{
Username: username,
Email: email,
}

if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}

return user, nil
}
// Файл: user_service_test.go
package service

import (
"context"
"errors"
"testing"
)

// MockUserRepository - мок для тестирования
type MockUserRepository struct {
GetByIDFunc func(ctx context.Context, id int) (*User, error)
CreateFunc func(ctx context.Context, user *User) error
UpdateFunc func(ctx context.Context, user *User) error
DeleteFunc func(ctx context.Context, id int) error
}

func (m *MockUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
if m.GetByIDFunc != nil {
return m.GetByIDFunc(ctx, id)
}
return nil, errors.New("не реализовано")
}

func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
if m.CreateFunc != nil {
return m.CreateFunc(ctx, user)
}
return errors.New("не реализовано")
}

func (m *MockUserRepository) Update(ctx context.Context, user *User) error {
if m.UpdateFunc != nil {
return m.UpdateFunc(ctx, user)
}
return errors.New("не реализовано")
}

func (m *MockUserRepository) Delete(ctx context.Context, id int) error {
if m.DeleteFunc != nil {
return m.DeleteFunc(ctx, id)
}
return errors.New("не реализовано")
}

func TestGetUser(t *testing.T) {
tests := []struct {
name string
userID int
mockFunc func(ctx context.Context, id int) (*User, error)
wantErr bool
wantUser *User
}{
{
name: "успешное получение пользователя",
userID: 1,
mockFunc: func(ctx context.Context, id int) (*User, error) {
return &User{ID: 1, Username: "john", Email: "john@example.com"}, nil
},
wantErr: false,
wantUser: &User{ID: 1, Username: "john", Email: "john@example.com"},
},
{
name: "пользователь не найден",
userID: 999,
mockFunc: func(ctx context.Context, id int) (*User, error) {
return nil, errors.New("пользователь не найден")
},
wantErr: true,
wantUser: nil,
},
{
name: "некорректный ID",
userID: -1,
mockFunc: nil,
wantErr: true,
wantUser: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := &MockUserRepository{
GetByIDFunc: tt.mockFunc,
}

service := NewUserService(mockRepo)
user, err := service.GetUser(context.Background(), tt.userID)

if (err != nil) != tt.wantErr {
t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr && user.Username != tt.wantUser.Username {
t.Errorf("GetUser() = %v, want %v", user, tt.wantUser)
}
})
}
}

func TestCreateUser(t *testing.T) {
tests := []struct {
name string
username string
email string
mockFunc func(ctx context.Context, user *User) error
wantErr bool
}{
{
name: "успешное создание",
username: "john",
email: "john@example.com",
mockFunc: func(ctx context.Context, user *User) error {
user.ID = 1
return nil
},
wantErr: false,
},
{
name: "пустой username",
username: "",
email: "john@example.com",
mockFunc: nil,
wantErr: true,
},
{
name: "пустой email",
username: "john",
email: "",
mockFunc: nil,
wantErr: true,
},
{
name: "ошибка репозитория",
username: "john",
email: "john@example.com",
mockFunc: func(ctx context.Context, user *User) error {
return errors.New("ошибка БД")
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRepo := &MockUserRepository{
CreateFunc: tt.mockFunc,
}

service := NewUserService(mockRepo)
_, err := service.CreateUser(context.Background(), tt.username, tt.email)

if (err != nil) != tt.wantErr {
t.Errorf("CreateUser() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

Бенчмарки (Benchmarking)

Что такое бенчмарки

Бенчмарки (benchmarks) — это автоматические тесты производительности, которые измеряют, насколько быстро и эффективно работает твой код (и сколько памяти он потребляет).

Они отвечают на вопросы:

  • Сколько времени занимает выполнение функции?
  • Какой подход быстрее: циклы, рекурсия, strings.Builder или обычный +?
  • Сколько памяти выделяется (allocations)?
  • Как код масштабируется при параллельном выполнении?

Как работают бенчмарки в Go

  • Пишешь функцию с именем BenchmarkXXX(b *testing.B).
  • Внутри цикла for i := 0; i < b.N; i++ { ... } выполняешь тестируемый код.
  • Go сам подбирает значение b.N (количество итераций), чтобы бенчмарк длился достаточно долго (обычно ~1 секунда) и результат был точным.
  • Запускаешь командой go test -bench=.

Пример

func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20) // тестируем функцию вычисления Фибоначчи
}
}

Go запустит эту функцию миллионы раз (b.N будет большим), измерит время и выдаст что-то вроде:

BenchmarkFibonacci-8    12345678    95.2 ns/op

Это значит:

  • На 8-ядерной машине
  • 12 миллионов итераций
  • В среднем 95.2 наносекунд на одну операцию

Полезные фишки

  • -benchmem — показывает выделение памяти:

    12345678    95.2 ns/op    48 B/op    3 allocs/op

    (сколько байт и сколько раз выделялась память на итерацию)

  • b.ReportAllocs() — включает отчёт о памяти в конкретном суб-бенчмарке.

  • b.ResetTimer() / b.StopTimer() / b.StartTimer() — управляют таймером, чтобы не учитывать подготовку данных.

  • b.RunParallel() — тестирует производительность в параллельных горутинах (важно для реальных приложений).

  • Таблица бенчмарков (b.Run) — сравниваешь разные реализации в одном тесте.

Зачем нужны бенчмарки?

  1. Выбираешь лучший алгоритм
    Например, сравниваешь конкатенацию строк через + и через strings.Builder — сразу видно, что Builder в десятки раз быстрее и не выделяет лишнюю память.

  2. Ловишь регрессии производительности
    После изменения кода запускаешь бенчмарки — если стало медленнее, сразу видно.

  3. Оптимизируешь код
    Понимаешь, где узкие места (много аллокаций, медленные операции).

  4. Проверяешь масштабируемость
    Параллельные бенчмарки показывают, как код ведёт себя под нагрузкой.

Как запускать и сравнивать

go test -bench=. -benchmem          # все бенчмарки с памятью
go test -bench=Fibonacci -count=10 # 10 прогонов для точности
go test -bench=. -benchtime=5s # прогонять дольше для стабильности

Для сравнения до/после изменений:

go test -bench=. -benchmem > old.txt
# меняешь код
go test -bench=. -benchmem > new.txt
benchstat old.txt new.txt # утилита для красивого сравнения

Примеры

// Файл: performance_test.go
package algorithms

import (
"testing"
)

// Простой бенчмарк
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}

// Бенчмарк с параметрами
func BenchmarkFibonacciTable(b *testing.B) {
tests := []struct {
name string
n int
}{
{"Fibonacci10", 10},
{"Fibonacci20", 20},
{"Fibonacci30", 30},
}

for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(tt.n)
}
})
}
}

// Бенчмарк с выделением памяти
func BenchmarkStringConcat(b *testing.B) {
b.Run("оператор +", func(b *testing.B) {
b.ReportAllocs() // Отображает информацию о выделении памяти
for i := 0; i < b.N; i++ {
result := ""
for j := 0; j < 100; j++ {
result += "x"
}
}
})

b.Run("strings.Builder", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var builder strings.Builder
for j := 0; j < 100; j++ {
builder.WriteString("x")
}
_ = builder.String()
}
})
}

// Бенчмарк с предварительной подготовкой
func BenchmarkSortLargeSlice(b *testing.B) {
// Данные создаются один раз перед началом измерений
data := generateRandomSlice(10000)

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

for i := 0; i < b.N; i++ {
b.StopTimer() // Останавливаем таймер для копирования данных
dataCopy := make([]int, len(data))
copy(dataCopy, data)
b.StartTimer() // Возобновляем таймер

sort.Ints(dataCopy)
}
}

// Параллельные бенчмарки
func BenchmarkParallelProcessing(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// Код, выполняемый параллельно
ProcessData(generateData())
}
})
}

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

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

# Запустить конкретный бенчмарк
go test -bench=BenchmarkFibonacci

# С информацией о памяти
go test -bench=. -benchmem

# Несколько прогонов для точности
go test -bench=. -count=5

# Запуск определенное время
go test -bench=. -benchtime=10s

# Сравнение производительности
go test -bench=. -benchmem > old.txt
# Вносим изменения в код
go test -bench=. -benchmem > new.txt
# Используем benchcmp для сравнения
benchcmp old.txt new.txt

Примеры

Примеры в Go служат как документацией, так и тестами:

// Файл: example_test.go
package stringutils

import (
"fmt"
)

// Простой пример
func ExampleReverse() {
result := Reverse("hello")
fmt.Println(result)
// Вывод: olleh
}

// Пример с множественным выводом
func ExampleSplit() {
parts := Split("one,two,three", ",")
for _, part := range parts {
fmt.Println(part)
}
// Вывод:
// one
// two
// three
}

// Пример для метода
func ExampleUser_FullName() {
user := User{
FirstName: "John",
LastName: "Doe",
}
fmt.Println(user.FullName())
// Вывод: John Doe
}

// Пример с неупорядоченным выводом
func ExampleGetUsers() {
users := GetUsers()
for id := range users {
fmt.Println(id)
}
// Unordered Вывод:
// 1
// 2
// 3
}

// Именованные примеры (варианты использования)
func ExampleReverse_unicode() {
result := Reverse("привет")
fmt.Println(result)
// Вывод: тевирп
}

func ExampleReverse_empty() {
result := Reverse("")
fmt.Println(result)
// Вывод:
}

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

Что такое интеграционное тестирование?

Интеграционное тестирование — это вид тестирования, который проверяет, как разные части программы работают вместе (интегрируются друг с другом), а не по отдельности.

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

Главная цель

Убедиться, что:

  • Модули, которые по отдельности работают правильно, вместе тоже работают корректно.
  • Данные правильно передаются между слоями (сервис → репозиторий → БД).
  • Нет проблем на стыках: ошибки маппинга, неправильные SQL-запросы, проблемы с транзакциями и т.д.

Пример

func TestUserCRUD(t *testing.T) {
db := setupTestDB(t) // подключение к настоящей тестовой БД
repo := NewUserRepository(db) // реальный репозиторий, который делает SQL-запросы

// Создаём, читаем, обновляем, удаляем пользователя
// Всё происходит в реальной базе данных
}

Здесь тестируется не только логика репозитория, но и:

  • Правильно ли подключение к PostgreSQL?
  • Правильно ли написаны SQL-запросы?
  • Корректно ли работает автоинкремент ID?
  • Правильно ли очищаются таблицы между тестами?

Это уже не юнит-тест, потому что зависит от внешней системы (БД).

Отличия от юнит-тестов

ХарактеристикаЮнит-тестыИнтеграционные тесты
Что тестируемОдин модуль в изоляцииВзаимодействие нескольких модулей
ЗависимостиМоки или заглушкиРеальные (БД, API, файлы) или тестовые
СкоростьОчень быстрые (миллисекунды)Медленнее (секунды)
ЗапускЧасто (при каждом go test)Реже, отдельно (с тегами, флагами)
НадёжностьОчень стабильныеМогут падать из-за внешних факторов
Когда писатьВсегда, для всей бизнес-логикиДля критических интеграций (БД, API)

Когда нужны интеграционные тесты

  • Работа с базой данных (GORM, sqlx, sql).
  • Взаимодействие с внешними сервисами (HTTP-клиенты, message brokers).
  • Сложные транзакции.
  • Миграции данных.
  • Полные сценарии (end-to-end без UI).

Как обычно организуют в Go

  • Помечают теги: //go:build integration или // +build integration.
  • Запускают отдельно: go test -tags=integration.
  • Пропускают в коротком режиме: if testing.Short() { t.Skip() }.
  • Используют тестовую БД (отдельная схема или Docker-контейнер).
  • Очищают состояние перед/после теста (setup/teardown).

Пример

// Файл: integration_test.go
// +build integration

package integration

import (
"context"
"database/sql"
"testing"
"time"

_ "github.com/lib/pq"
)

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

// Подключение к тестовой БД
db, err := sql.Open("postgres", "postgresql://user:pass@localhost/testdb?sslmode=disable")
if err != nil {
t.Fatalf("не удалось подключиться к БД: %v", err)
}

// Очистка данных перед тестом
_, err = db.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
if err != nil {
t.Fatalf("не удалось очистить таблицу: %v", err)
}

return db
}

func teardownTestDB(t *testing.T, db *sql.DB) {
t.Helper()
if err := db.Close(); err != nil {
t.Errorf("ошибка закрытия БД: %v", err)
}
}

func TestUserCRUD(t *testing.T) {
if testing.Short() {
t.Skip("пропуск интеграционного теста в коротком режиме")
}

db := setupTestDB(t)
defer teardownTestDB(t, db)

repo := NewUserRepository(db)
ctx := context.Background()

// Создание пользователя
t.Run("создание пользователя", func(t *testing.T) {
user := &User{
Username: "john_doe",
Email: "john@example.com",
}

err := repo.Create(ctx, user)
if err != nil {
t.Fatalf("ошибка создания пользователя: %v", err)
}

if user.ID == 0 {
t.Error("ID пользователя должен быть установлен")
}
})

// Получение пользователя
t.Run("получение пользователя", func(t *testing.T) {
user, err := repo.GetByID(ctx, 1)
if err != nil {
t.Fatalf("ошибка получения пользователя: %v", err)
}

if user.Username != "john_doe" {
t.Errorf("username = %s; ожидается john_doe", user.Username)
}
})

// Обновление пользователя
t.Run("обновление пользователя", func(t *testing.T) {
user, _ := repo.GetByID(ctx, 1)
user.Email = "newemail@example.com"

err := repo.Update(ctx, user)
if err != nil {
t.Fatalf("ошибка обновления пользователя: %v", err)
}

updatedUser, _ := repo.GetByID(ctx, 1)
if updatedUser.Email != "newemail@example.com" {
t.Errorf("email не обновлен")
}
})

// Удаление пользователя
t.Run("удаление пользователя", func(t *testing.T) {
err := repo.Delete(ctx, 1)
if err != nil {
t.Fatalf("ошибка удаления пользователя: %v", err)
}

_, err = repo.GetByID(ctx, 1)
if err == nil {
t.Error("пользователь должен быть удален")
}
})
}

// Запуск интеграционных тестов:
// go test -tags=integration ./...

Тестирование конкурентного кода

// Файл: concurrent_test.go
package cache

import (
"sync"
"testing"
"time"
)

func TestCacheConcurrency(t *testing.T) {
cache := NewCache()

const numGoroutines = 100
const numOperations = 1000

var wg sync.WaitGroup
wg.Add(numGoroutines * 2) // Читатели и писатели

// Писатели
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Set(key, j)
}
}(i)
}

// Читатели
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Get(key)
}
}(i)
}

// Таймаут для обнаружения дедлоков
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()

select {
case <-done:
// Все горутины завершились успешно
case <-time.After(10 * time.Second):
t.Fatal("тест превысил таймаут - возможен дедлок")
}
}

func TestRaceCondition(t *testing.T) {
counter := 0
var mu sync.Mutex

const numGoroutines = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)

for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
counter++
mu.Unlock()
}
}()
}

wg.Wait()

expected := numGoroutines * 1000
if counter != expected {
t.Errorf("counter = %d; ожидается %d", counter, expected)
}
}

// Запуск с детектором гонок данных:
// go test -race

Нагрузочное тестирование

RPS

RPS (Requests Per Second) — количество HTTP-запросов, которое сервер способен обработать за 1 секунду.

Почему RPS важен:

  • показывает пропускную способность сервера;
  • помогает понять, сколько пользователей выдержит сервис;
  • используется при:
    • нагрузочном тестировании;
    • capacity planning;
    • сравнении реализаций.


Высокий RPS не гарантирует, что сервер быстрый или стабильный — всегда смотрим вместе с latency и error rate.

Минимальный HTTP-сервер на net/http

Начнём с самого простого сервера.

package main

import (
"net/http"
)

func main() {
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("pong"))
})

http.ListenAndServe(":8080", nil)
}

Что важно:

  • net/http уже многопоточный — каждый запрос обрабатывается в отдельной goroutine;
  • лимиты по RPS упираются не в Go, а в:
    • CPU
    • I/O
    • аллокации
    • блокировки

Подготовка к нагрузочному тестированию

Базовые правила:

  1. Тестируем локально (без Docker, VPN и Wi-Fi)
  2. Отключаем лишние процессы
  3. Тестируем Release-сборку
  4. Смотрим CPU и память
go build -o server
./server

Инструменты для измерения RPS

ИнструментКогда использовать
wrkБыстрые тесты
heyПростой CLI
abУстаревший
k6Сценарии и CI
собственный Go-бенчмаркТочный контроль

В этом уроке используем wrk и Go-клиент.

Тестирование RPS с помощью wrk

Установка:

brew install wrk

Простой тест:

wrk -t4 -c100 -d10s http://localhost:8080/ping

Расшифровка параметров:

  • -t4 → 4 потока
  • -c100 → 100 одновременных соединений
  • -d10s → тест 10 секунд

Пример результата

Requests/sec: 185000.32
Latency 520µs

Это значит:

  • сервер обрабатывает ~185k RPS
  • средняя задержка ~0.5 мс

Как понять, что RPS «хороший»?

Тип сервисаНормальный RPS
Ping / healthcheck100k+
JSON API10k–50k
API + БД1k–10k
Тяжёлая логика< 1k

👉 Всегда тестируем реальный код, а не пустой handler.

Реалистичный handler (JSON + логика)

http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
data := map[string]string{
"id": "42",
"name": "Artem",
}

b, _ := json.Marshal(data)
w.Header().Set("Content-Type", "application/json")
w.Write(b)
})


RPS упадёт — и это нормально.

Нагрузочное тестирование из Go (без wrk)

func main() {
client := &http.Client{}
var wg sync.WaitGroup

start := time.Now()
total := 100_000

for i := 0; i < total; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("GET", "http://localhost:8080/ping", nil)
client.Do(req)
}()
}

wg.Wait()

fmt.Println("RPS:", float64(total)/time.Since(start).Seconds())
}

Подходит для:

  • экспериментов
  • сравнений
  • кастомных сценариев

Типичные бутылочные горлышки RPS

Что чаще всего ломает RPS:

  • log.Println внутри handler
  • аллокации (map, json.Marshal)
  • mutex
  • чтение из БД
  • синхронный I/O

Что помогает:

  • sync.Pool
  • pre-allocated buffers
  • json.Encoder
  • async обработка
  • pprof

Анализ через pprof

import _ "net/http/pprof"

go func() {
http.ListenAndServe(":6060", nil)
}()
go tool pprof http://localhost:6060/debug/pprof/profile

Обратите внимание на:

  • CPU
  • allocs
  • goroutines

Лучшие практики тестирования

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

// Хорошо
func TestUserService_CreateUser_WithValidData_ReturnsUser(t *testing.T) {}
func TestUserService_CreateUser_WithEmptyUsername_ReturnsError(t *testing.T) {}

// Плохо
func TestUser1(t *testing.T) {}
func TestCreateUser(t *testing.T) {}

2. Организация тестов

// Хорошо: используйте табличные тесты и подтесты
func TestValidation(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
// тестовые случаи
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// тест
})
}
}

3. Изоляция тестов

// Хорошо: каждый тест независим
func TestFeatureA(t *testing.T) {
data := setupTestData()
defer cleanupTestData(data)
// тест
}

// Плохо: тесты зависят друг от друга
var globalState int

func TestFeatureA(t *testing.T) {
globalState = 1
}

func TestFeatureB(t *testing.T) {
// Зависит от TestFeatureA
if globalState != 1 {
t.Fatal("неверное состояние")
}
}

4. Тестирование границ

func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantError bool
}{
{"обычный случай", 10, 2, 5, false},
{"деление на ноль", 10, 0, 0, true},
{"отрицательные числа", -10, 2, -5, false},
{"очень большие числа", 1e308, 2, 5e307, false},
{"очень маленькие числа", 1e-308, 2, 5e-309, false},
}
// ...
}

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

# Генерация отчета о покрытии
go test -coverprofile=coverage.out

# Просмотр процента покрытия
go tool cover -func=coverage.out

# Визуализация покрытия в браузере
go tool cover -html=coverage.out

# Покрытие для всех пакетов
go test -coverprofile=coverage.out ./...

# Покрытие с детализацией по пакетам
go test -coverpkg=./... -coverprofile=coverage.out ./...

Тестовые утилиты

// Файл: testutil/testutil.go
package testutil

import (
"io/ioutil"
"os"
"path/filepath"
"testing"
)

// CreateTempDir создает временную директорию для тестов
func CreateTempDir(t *testing.T) string {
t.Helper()

dir, err := ioutil.TempDir("", "test-*")
if err != nil {
t.Fatalf("не удалось создать временную директорию: %v", err)
}

t.Cleanup(func() {
os.RemoveAll(dir)
})

return dir
}

// CreateTestFile создает тестовый файл с содержимым
func CreateTestFile(t *testing.T, dir, filename, content string) string {
t.Helper()

path := filepath.Join(dir, filename)
if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("не удалось создать тестовый файл: %v", err)
}

return path
}

// AssertEqual проверяет равенство значений
func AssertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}

// AssertError проверяет наличие ошибки
func AssertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Error("ожидалась ошибка, получено nil")
}
}

// AssertNoError проверяет отсутствие ошибки
func AssertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("неожиданная ошибка: %v", err)
}
}