Тестирование в 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.Code | HTTP-код, например 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(...)— корректность взаимодействия с хранилищем.
Логика для новичка:
NewService(store)принимаетUserStore— любую реализацию.- В продакшене — реальная БД; в тесте —
fakeStore, который только запоминает имена в слайс. - Проверяем: сервис вызвал
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)
}
}
Разбор:
quick.Checkгенерирует случайные входы и ищет counterexample.- Fuzz (
-fuzz) глубже и сохраняет crashing corpus — см. Профилирование, trace и fuzz в Go.
Кросс-компиляция
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 |
Что попробовать
- Переписать один существующий тест в table-driven вид с тремя строками в таблице.
- Добавить
Example*к публичной функции из вашего пакета. - Прогнать
go test -raceдля сервиса с Gin и mutex в handlers.
Пример теста с проверкой ошибок:
Код ITЗагрузка примера кода…
Разбор:
- Поле
wantErrпозволяет тестировать и успешные, и ошибочные сценарии в одном шаблоне таблицы. - Выражение
(err != nil) != tt.wantErrпроверяет соответствие факта ошибки ожидаемому состоянию. t.Fatalfостанавливает подтест сразу, когда базовое условие нарушено.- Проверка
got != tt.wantвыполняется только после валидной проверки ошибки и даёт точный отчёт по результату.
go test -race
Разбор:
- Флаг
-raceвключает runtime-инструментацию доступа к памяти между горутинами. - Команда выявляет конфликтующие чтения/записи, которые не защищены синхронизацией.
- Такой прогон особенно важен для кода с каналами,
sync.Mutexи фоновыми воркерами.
Стратегия набора тестов
Хороший набор тестов строится слоями:
- Быстрые unit-тесты на чистую логику.
- Тесты handlers через
httptest. - Интеграционные тесты с реальной БД или контейнером.
Такой баланс даёт быструю обратную связь в разработке и реалистичную проверку критичных сценариев в CI.
Что покрывать в первую очередь
- преобразование входных данных и валидацию;
- доменные правила и пограничные условия;
- обработку ошибок внешних зависимостей;
- HTTP-контракты — код ответа, структура JSON, обязательные поля.
Покрытие по проценту полезно как ориентир, но главная цель тестов — контроль поведения в реальных сценариях.
Чеклист качества теста
- Ясное имя (
TestCreateUser_InvalidEmail_Returns400). - Один проверяемый смысл на тест.
- Понятный
gotиwantв сообщениях ошибок. - Нет зависимости от текущего времени без фиксации.
- Нет зависимости от сети и глобального состояния без изоляции.
Ключевые тезисы
- В Go тестирование строится вокруг
testing,go testи понятного именования. - Табличные тесты уменьшают дублирование и улучшают диагностику.
- Полезный набор включает unit, HTTP-тесты и интеграционные проверки.
Мини-практикум
- Переведите один старый тест в table-driven формат.
- Добавьте тест handler через
httptest.NewRecorder. - Запустите
go test -race -cover ./...и зафиксируйте проблемные зоны.
Типичные ошибки
- Тесты проверяют только happy-path без негативных сценариев.
- Сообщения ошибок не содержат
gotиwant. - В CI нет
-race, и гонки появляются только в проде.
Связанные материалы
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.