Тестирование Go-кода
Тесты нужны не для галочки и не только для CI. Хороший тест фиксирует ожидание: если функция получает такие данные, она должна вернуть такой результат или такую ошибку. Когда вы меняете код, тесты показывают, что важное поведение осталось прежним.
В Go тесты пишутся без отдельного фреймворка. Достаточно стандартного пакета testing и файлов с суффиксом _test.go.
Первый тест
Код:
func Add(a, b int) int { return a + b }
Тест:
func TestAdd(t *testing.T) { got := Add(2, 3) want := 5 if got != want { t.Fatalf("Add(2, 3) = %d, want %d", got, want) } }
Запуск:
go test ./...
Функция теста должна начинаться с Test, принимать *testing.T и лежать в файле _test.go.
got и want
В Go-тестах часто используют имена got и want:
got := NormalizeName(" Alice ") want := "alice" if got != want { t.Fatalf("got %q, want %q", got, want) }
Так сообщение об ошибке сразу показывает фактический и ожидаемый результат.
Table-driven tests
Если случаев несколько, используйте таблицу:
func TestIsValidPort(t *testing.T) { tests := []struct { name string port int want bool }{ {name: "zero", port: 0, want: false}, {name: "http", port: 80, want: true}, {name: "too high", port: 70000, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsValidPort(tt.port) if got != tt.want { t.Fatalf("got %v, want %v", got, tt.want) } }) } }
Так легче добавлять новые случаи и понимать, какой сценарий сломался.
Проверка ошибок
Если ошибка ожидается:
_, err := ParseConfig("missing.yaml") if err == nil { t.Fatal("expected error, got nil") }
Если важен тип ошибки:
if !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected os.ErrNotExist, got %v", err) }
Не сравнивайте текст ошибки без необходимости. Текст может измениться, а смысл ошибки остаться тем же.
t.Helper
Если вы выносите повторяющуюся проверку в функцию, добавьте t.Helper():
func requireNoError(t *testing.T, err error) { t.Helper() if err != nil { t.Fatalf("unexpected error: %v", err) } }
Тогда Go покажет строку, где helper был вызван, а не строку внутри helper.
Временные файлы
Не создавайте тестовые файлы вручную в проекте. Используйте t.TempDir():
func TestReadFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "data.txt") err := os.WriteFile(path, []byte("hello"), 0644) if err != nil { t.Fatal(err) } got, err := ReadFile(path) if err != nil { t.Fatal(err) } if got != "hello" { t.Fatalf("got %q", got) } }
Go сам удалит временную папку после теста.
Тестирование через интерфейсы
Если сервис зависит от внешнего API или базы, спрячьте зависимость за интерфейсом:
type WeatherClient interface { Current(ctx context.Context, city string) (Weather, error) }
В тесте можно использовать fake:
type fakeWeatherClient struct { weather Weather } func (f fakeWeatherClient) Current(ctx context.Context, city string) (Weather, error) { return f.weather, nil }
Так тест бизнес-логики не зависит от интернета.
Тестирование конкурентного кода
Для поиска гонок:
go test -race ./...
Флаг -race замедляет запуск, но хорошо ловит ошибки общего состояния. Используйте его для кода с goroutine, mutex, channels и shared maps.
Покрытие
go test -cover ./...
Покрытие полезно как индикатор, но не как цель. Лучше 60% осмысленных тестов, чем 95% проверок, которые ничего не ловят.
Для HTML-отчета:
go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out
Benchmarks
Benchmark измеряет производительность:
func BenchmarkNormalizeName(b *testing.B) { for i := 0; i < b.N; i++ { NormalizeName(" Alice ") } }
Запуск:
go test -bench=. ./...
Не начинайте оптимизацию с benchmark, если нет проблемы. Но когда проблема есть, benchmark помогает сравнивать решения.
Частые команды
go test ./... go test ./internal/parser go test -run TestParse ./... go test -race ./... go test -cover ./... go test -v ./... go test -bench=. ./...
Что тестировать первым
- Парсинг входных данных.
- Валидацию.
- Алгоритмы.
- Работу с ошибками.
- Бизнес-правила.
- Код, который легко сломать при рефакторинге.
UI, сеть и внешние API лучше отделять интерфейсами, чтобы тестировать бизнес-логику без реального интернета.