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

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

Разработчику Архитектору

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

Автотест — программа, которая проверяет другую программу: вызывает функцию с известными входами и сравнивает результат с ожидаемым. Если результат другой — тест проваливается, и CI (сборка в GitHub Actions, GitLab и т.д.) помечает коммит как сломанный.

В Go тесты — часть экосистемы — отдельный пакет testing в стандартной библиотеке, команда go test, именование файлов *_test.go. Сторонний фреймворк для "простого unit-теста" обычно не нужен; для удобных проверок часто подключают testify, для HTTP — net/http/httptest.

Идиоматичный стиль в Go — table-driven tests — один тест-функция, таблица кейсов в слайсе, цикл с t.Run для каждой строки.

База: Первая программа на Go · Справочник по Go · Фреймворки · HTTP: Gin.


Что получится

Вы поймёте, как назвать и запустить тест, как читать got / want, как писать табличные кейсы, как проверить HTTP-handler и как подменить зависимость через интерфейс без "магических" мок-библиотек.

Минимальный пример функции, которую удобно тестировать:

func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}

Разбор:

  • strings.TrimSpace удаляет пробелы и переводы строк по краям входной строки.
  • strings.ToLower нормализует регистр, чтобы одинаковые email сравнивались предсказуемо.
  • Функция детерминированная и без внешних зависимостей, поэтому идеально подходит для unit-тестов.

Термины

ТерминПростыми словами
Unit-тестПроверка одной функции или маленького модуля изолированно
Пакет (package)Папка с Go-кодом; тесты лежат в том же пакете, что и код
testing.TОбъект "текущий тест": сюда пишут ошибки (Errorf) и подтесты (Run)
got / wantФактический результат и ожидаемый (принятые имена в сообщениях)
Покрытие (coverage)Доля строк кода, которую хотя бы раз выполнили тесты
BenchmarkЗамер скорости: сколько раз в секунду вызывается функция
Fake / mockПодмена зависимости: fake — простая ручная реализация; mock — часто генерируемый

Именование и запуск

Правила имён

  • Файл: имя_test.go рядом с кодом, например greeting.go и greeting_test.go.
  • Функция теста — Test + заглавная буква + суффикс, например TestHello, TestAdd.
  • Пакет в тесте обычно тот же, что у кода (package greeting), чтобы видеть неэкспортируемые функции.

Первый тест — построчно

package greeting

import "testing"

func TestHello(t *testing.T) {
got := Hello("Go")
want := "Hello, Go!"
if got != want {
t.Errorf("Hello() = %q, want %q", got, want)
}
}

Разбор:

  • package greeting задаёт пакет, в котором находится тест и тестируемый код.
  • import "testing" подключает стандартный пакет, через который go test управляет запуском.
  • func TestHello(t *testing.T) распознаётся как тест из-за префикса Test и сигнатуры с *testing.T.
  • Переменные got и want фиксируют фактический и ожидаемый результат, чтобы сравнение было читаемым.
  • t.Errorf(...) помечает тест как проваленный, но не останавливает выполнение немедленно.
СтрокаСмысл
func TestHello(t *testing.T)Go находит все Test* и запускает их через go test
got := Hello("Go")Вызываем тестируемую функцию
want := "Hello, Go!"Ожидаемый ответ — задаём явно
if got != wantСравнение; при несовпадении тест должен упасть
t.Errorf(...)Сообщение об ошибке; %q печатает строку в кавычках

Тест не возвращает error — он сообщает о провале через t.Error / t.Fatal (последний сразу останавливает тест).


Команды

go test ./... # все пакеты в дереве каталогов
go test -v ./pkg/greeting # -v — подробный вывод (имена тестов)
go test -cover ./... # процент покрытия по пакетам
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out # HTML-отчёт в браузере

Разбор:

  • go test ./... запускает тесты рекурсивно по всем пакетам от текущей директории.
  • Флаг -v выводит имена тестов и подтестов, что ускоряет диагностику падений.
  • -cover показывает базовый процент покрытия, а -coverprofile сохраняет детальный профиль.
  • go tool cover -html=... визуализирует покрытие по строкам, чтобы находить непроверенные ветки.

./... — идиома Go: "все пакеты от текущей папки вниз".


Table-driven tests

Идея: данные отделены от логики. Добавить новый кейс — одна строка в таблице, без копирования всего тела теста.

Код ITЗагрузка примера кода…

Разбор:

  • []struct { ... } — анонимный тип "строка таблицы" — имя кейса, входы, ожидание.
  • for _, tt := range tests_ пропускает индекс; tt — одна строка.
  • t.Run(tt.name, func(t *testing.T) { ... })подтест с именем zeros, positive… В отчёте и в IDE видно, какая строка упала.
  • if got := Add(...); got != tt.want — короткое объявление got прямо в условии.

Такой стиль рекомендуют в официальной документации и в коде стандартной библиотеки.


testify — удобные проверки

Пакет github.com/stretchr/testify не заменяет testing, а добавляет читаемые assert-функции.

go get github.com/stretchr/testify

Разбор:

  • Команда добавляет зависимость testify в модуль и фиксирует её в go.mod/go.sum.
  • Пакет не заменяет testing, а расширяет его удобными assert/require-хелперами.
  • После установки библиотеку можно использовать в unit-тестах и HTTP-тестах для более читаемых проверок.

import (

"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHelloAssert(t *testing.T) {
assert.Equal(t, "Hi, Ann", Hello("Ann"))
require.NotEmpty(t, Hello("x"))
}

Разбор:

  • Импорты assert и require дают два режима проверки с разной строгостью остановки.
  • assert.Equal(...) логирует ошибку и позволяет тесту выполнить оставшиеся проверки.
  • require.NotEmpty(...) завершает тест сразу при провале, чтобы не продолжать в некорректном состоянии.
  • Такой подход помогает разделять "критичные предусловия" и "обычные проверки результата".
ФункцияПоведение
assert.EqualПри провале — ошибка в лог, тест идёт дальше
require.NotEmptyПри провале — сразу стоп теста (как t.Fatal)

Используйте require, когда дальнейшие строки бессмысленны без успешной проверки (например, после создания временного файла).

Другие полезные вызовы: assert.NoError, assert.Len, assert.Contains — меньше шаблонного if err != nil.


HTTP handler — httptest

Handler — функция, которая принимает запрос и пишет ответ. Тестировать "живой" сервер на порту 8080 не обязательно: пакет httptest даёт фейковый запрос и запись ответа в память.

func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()

HealthHandler(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "ok")
}

Разбор:

  • httptest.NewRequest(...) создаёт искусственный HTTP-запрос без реального сетевого сокета.
  • httptest.NewRecorder() принимает роль ResponseWriter и сохраняет код, заголовки и тело ответа.
  • Вызов HealthHandler(rec, req) запускает handler в изоляции, как будто запрос пришёл в сервер.
  • Проверки rec.Code и rec.Body.String() подтверждают контракт эндпоинта на уровне транспорта.
ЧастьНазначение
NewRequestИмитация GET /health без сети
NewRecorder"Буфер" ответа: код статуса, заголовки, тело
HealthHandler(rec, req)Вызов handler как в net/http
rec.CodeHTTP-код, например 200
rec.Body.String()Тело ответа текстом

Для Gin маршрутизатор сам пишет в recorder:

router := gin.New()
router.GET("/health", healthHandler)
router.ServeHTTP(rec, req)

Разбор:

  • gin.New() создаёт минимальный роутер без дефолтных middleware.
  • router.GET(...) регистрирует маршрут и связывает его с обработчиком.
  • router.ServeHTTP(rec, req) прогоняет запрос через роутер и middleware-цепочку в памяти.
  • Такой формат позволяет тестировать не отдельную функцию, а поведение всего маршрута.

Так тестируют маршруты и middleware без go run и без curl.


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

Go не генерирует моки в языке. Паттерн: зависимость описывают интерфейсом, в тесте подставляют fake — простую структуру с нужным поведением.

Код ITЗагрузка примера кода…

Разбор:

  • Интерфейс UserStore отделяет бизнес-логику сервиса от конкретной реализации хранилища.
  • fakeStore реализует этот интерфейс и накапливает входные данные в saved, что удобно для проверки побочного эффекта.
  • В тесте NewService(store) получает подменённую зависимость, поэтому тест остаётся быстрым и детерминированным.
  • Проверка require.NoError(...) валидирует отсутствие сбоя, а assert.Equal(...) — корректность взаимодействия с хранилищем.

Логика для новичка:

  1. NewService(store) принимает UserStore — любую реализацию.
  2. В продакшене — реальная БД; в тесте — fakeStore, который только запоминает имена в слайс.
  3. Проверяем: сервис вызвал Save с "Ann" и не вернул ошибку.

Для больших проектов генерируют моки: mockgen (gomock), moq. На старте достаточно ручного fake.


Benchmark (замер скорости)

Имя функции: Benchmark + имя, аргумент *testing.B.

func BenchmarkHello(b *testing.B) {
for i := 0; i < b.N; i++ {
Hello("bench")
}
}

Разбор:

  • Функция с префиксом Benchmark и параметром *testing.B распознаётся как бенчмарк.
  • Цикл до b.N выполняется столько раз, сколько нужно фреймворку для стабильной статистики.
  • Внутри цикла должен быть только измеряемый код, иначе в замер попадут посторонние накладные расходы.
  • Результат показывает скорость выполнения и помогает находить регрессии производительности.

b.N подбирает go test так, чтобы замер был стабильным. Запуск:

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

Разбор:

  • -bench=. запускает все бенчмарки в найденных пакетах.
  • -benchmem добавляет статистику по аллокациям и байтам на операцию.
  • Совместный запуск с ./... позволяет сравнить производительность сразу на уровне всего проекта.

-benchmem — аллокации памяти на итерацию (полезно при оптимизации).


Примеры и гонки

Пример (Example*) — код в документации, который тоже проверяется go test:

func ExampleHello() {
fmt.Println(Hello("World"))
// Output: Hello, World!
}

Разбор:

  • ExampleHello служит одновременно документацией и автоматически проверяемым примером.
  • fmt.Println(...) выводит фактический результат в stdout.
  • Комментарий // Output: задаёт эталон, с которым go test сравнит реальный вывод.
  • Если вывод отличается даже по пробелам, пример помечается как упавший тест.

Комментарий // Output: — ожидаемый stdout; несовпадение = провал теста.

Детектор гонок — флаг -race при go test: ловит одновременную запись в одну переменную из разных горутин без синхронизации. Обязателен для кода с go и mutex.


CI (непрерывная интеграция)

Минимальный шаг в GitHub Actions:

- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test -race -coverprofile=coverage.out ./...

Разбор:

  • Шаг actions/setup-go@v5 подготавливает нужную версию Go в CI-окружении.
  • Поле go-version фиксирует версию рантайма для воспроизводимых сборок.
  • Команда go test -race ... объединяет функциональные тесты с поиском гонок данных.
  • -coverprofile сохраняет отчёт покрытия, который можно публиковать как артефакт или проверять порогами.
ФлагЗачем
-raceГонки данных в многопоточном коде
-coverprofile=...Артефакт покрытия для отчётов или порогов в CI

Порог "не ниже 80% coverage" задают политикой команды; сам по себе процент не гарантирует качество тестов. Fuzz и pprof — профилирование и fuzz.


t.TempDir и t.Cleanup

С Go 1.14+ тесты получают автоматическую уборку через t.TempDir и t.Cleanup:

func TestSaveContact(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "book.json")

t.Cleanup(func() {
// дополнительный teardown после TempDir
})

if err := save(path, Contact{Name: "Ann"}); err != nil {
t.Fatal(err)
}
}

Разбор:

  • t.TempDir() создаёт уникальный каталог; после теста Go удаляет его автоматически.
  • t.Cleanup(fn) регистрирует callback в порядке LIFO — аналог defer на уровне теста.
  • Удобно закрывать БД, HTTP-сервер, временные файлы без дублирования в каждом t.Fatal-пути.

testing/quick

Property-подобные проверки без полноценного fuzz:


import "testing/quick"

func TestEncodeDecodeRoundTrip(t *testing.T) {
f := func(n int16) bool {
if n < 0 {
return true // quick может генерировать любые значения
}
return decode(encode(n)) == n
}
if err := quick.Check(f, nil); err != nil {
t.Fatal(err)
}
}

Разбор:


Кросс-компиляция

GOOS=linux GOARCH=amd64 go test ./...
GOOS=windows GOARCH=amd64 go build -o app.exe ./cmd/app

Разбор:

  • Тесты можно гонять под target OS в CI matrix.
  • С CGO (import "C") кросс-сборка усложняется — нужен cross-compiler.
  • Артефакты для релиза часто собирают в Docker с GOOS/GOARCH — см. Пример микросервиса на Go.

Частые ошибки

СимптомПричина
no test filesФайл не *_test.go или функция не TestXxx
Тест в другом пакете не видит helloНужен package greeting_test и импорт пакета — отдельная тема для black-box тестов
Падает только на CIРазный часовой пояс, пути, отсутствие -race локально
httptest пустое телоHandler не вызван или не тот метод/URL

Что попробовать

  1. Переписать один существующий тест в table-driven вид с тремя строками в таблице.
  2. Добавить Example* к публичной функции из вашего пакета.
  3. Прогнать go test -race для сервиса с Gin и mutex в handlers.

Пример теста с проверкой ошибок:

Код ITЗагрузка примера кода…

Разбор:

  • Поле wantErr позволяет тестировать и успешные, и ошибочные сценарии в одном шаблоне таблицы.
  • Выражение (err != nil) != tt.wantErr проверяет соответствие факта ошибки ожидаемому состоянию.
  • t.Fatalf останавливает подтест сразу, когда базовое условие нарушено.
  • Проверка got != tt.want выполняется только после валидной проверки ошибки и даёт точный отчёт по результату.
go test -race

Разбор:

  • Флаг -race включает runtime-инструментацию доступа к памяти между горутинами.
  • Команда выявляет конфликтующие чтения/записи, которые не защищены синхронизацией.
  • Такой прогон особенно важен для кода с каналами, sync.Mutex и фоновыми воркерами.

Стратегия набора тестов

Хороший набор тестов строится слоями:

  1. Быстрые unit-тесты на чистую логику.
  2. Тесты handlers через httptest.
  3. Интеграционные тесты с реальной БД или контейнером.

Такой баланс даёт быструю обратную связь в разработке и реалистичную проверку критичных сценариев в CI.


Что покрывать в первую очередь

  • преобразование входных данных и валидацию;
  • доменные правила и пограничные условия;
  • обработку ошибок внешних зависимостей;
  • HTTP-контракты — код ответа, структура JSON, обязательные поля.

Покрытие по проценту полезно как ориентир, но главная цель тестов — контроль поведения в реальных сценариях.


Чеклист качества теста

  • Ясное имя (TestCreateUser_InvalidEmail_Returns400).
  • Один проверяемый смысл на тест.
  • Понятный got и want в сообщениях ошибок.
  • Нет зависимости от текущего времени без фиксации.
  • Нет зависимости от сети и глобального состояния без изоляции.

Ключевые тезисы

  • В Go тестирование строится вокруг testing, go test и понятного именования.
  • Табличные тесты уменьшают дублирование и улучшают диагностику.
  • Полезный набор включает unit, HTTP-тесты и интеграционные проверки.

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

  1. Переведите один старый тест в table-driven формат.
  2. Добавьте тест handler через httptest.NewRecorder.
  3. Запустите go test -race -cover ./... и зафиксируйте проблемные зоны.

Типичные ошибки

  • Тесты проверяют только happy-path без негативных сценариев.
  • Сообщения ошибок не содержат got и want.
  • В CI нет -race, и гонки появляются только в проде.

Связанные материалы


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.