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

Тестирование в Go: unit-тесты, table-driven tests, бенчмарки и моки. Практические примеры и паттерны. Полное руководство для Golang разработчиков.
- теги
- #Go #Тестирование #Программирование #Бенчмарки #Качество Кода
- категории
- Programming
- опубликовано
Тестирование в 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 из коробки
Главные принципы:
- Тестируй поведение, не реализацию
- Используй table-driven tests
- Моки через интерфейсы
- Бенчмаркай перед оптимизацией
Если хочешь автоматизировать написание тестов — Cursor AI неплохо справляется с генерацией table-driven tests по примеру.
Есть вопросы по тестированию в Go? Пиши в комментарии — разберём конкретные кейсы.