Если вы раньше писали тесты на других языках, вы привыкли устанавливать тестовый фреймворк, настраивать раннер и подключать библиотеку для моков. В Go этого нет: пакет testing уже в стандартной библиотеке, запуск — одна команда go test, а подмена зависимостей делается через обычные интерфейсы без внешних инструментов.
Разберём всё по порядку: как написать первый тест, как удобно покрыть несколько случаев сразу, как проверить HTTP-обработчики и как протестировать запросы к реальной базе.
Как устроен тест в Go
Тест — это функция с именем TestXxx и аргументом *testing.T. Она живёт в файле *_test.go рядом с основным кодом.
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2, 3) = %d, хотели 5", got)
}
}
Запуск:
go test ./...
Точка и ./... — это «все пакеты проекта». Один пакет: go test ./internal/order/.
Методы *testing.T, которые нужны чаще всего:
t.Errorf(...)— зафиксировать ошибку, тест продолжается;t.Fatalf(...)— зафиксировать ошибку и сразу остановить тест;t.Helper()— пометить вспомогательную функцию, чтобы в выводе ошибки показывался вызывающий код, а не строка внутри помощника.
Table-driven тесты
Допустим, у вас есть функция, которую надо проверить на десяти разных входах. Писать десять функций Test... — неудобно. Идиоматичный способ в Go — один тест, срез с кейсами, один цикл.
func TestMapError(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"not found", ErrProductNotFound, http.StatusNotFound},
{"forbidden", ErrForbidden, http.StatusForbidden},
{"другая ошибка", errors.New("boom"), http.StatusInternalServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mapError(tc.err); got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
t.Run создаёт подтест с именем. Если один кейс упадёт, остальные продолжат выполняться. В выводе вы увидите, какой именно кейс не прошёл: TestMapError/not_found.
Это удобнее, чем несколько функций: добавить новый случай — одна строка в срез.
Проверка HTTP-обработчиков без запуска сервера
Обычно чтобы проверить HTTP-обработчик, нужно поднять сервер, отправить запрос по сети, разобрать ответ. Это медленно и хрупко.
Пакет httptest из стандартной библиотеки позволяет делать то же самое в памяти, без реальной сети.
func TestGetProduct(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/products/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("статус: хотели 200, получили %d", rec.Code)
}
}
httptest.NewRequest создаёт запрос, httptest.NewRecorder — «запись» ответа. Метод ServeHTTP вызывает ваш обработчик напрямую. Так проверяется весь путь: роутинг, middleware, сам обработчик.
Есть и другой вариант — httptest.NewServer, который поднимает настоящий HTTP-сервер на случайном порту. Это нужно, когда вы хотите проверить поведение клиента, а не сервера.
Подмена зависимостей через интерфейсы
В Go нет контейнера зависимостей и нет магических библиотек моков. Подмена работает через обычные интерфейсы.
Ключевая идея: интерфейс объявляет тот, кто потребляет, а не тот, кто реализует. И интерфейс маленький — только те методы, которые реально нужны.
type productSaver interface {
Save(ctx context.Context, cmd CreateProductCommand) (Product, error)
}
type CreateProductHandler struct {
repo productSaver
}
CreateProductHandler ничего не знает о реальном репозитории. В тесте подставляем заглушку:
type stubSaver struct{ saved Product }
func (s *stubSaver) Save(_ context.Context, _ CreateProductCommand) (Product, error) {
return s.saved, nil
}
func TestCreateProduct(t *testing.T) {
stub := &stubSaver{saved: Product{ID: 42}}
h := CreateProductHandler{repo: stub}
result, err := h.Handle(context.Background(), CreateProductCommand{Name: "Тест"})
if err != nil {
t.Fatal(err)
}
if result.ID != 42 {
t.Errorf("хотели ID=42, получили %d", result.ID)
}
}
Никакой базы, никакого сетевого обращения — тест работает мгновенно.
Заглушку пишут вручную: это несколько строк, зато видно точно, что происходит. Когда интерфейс большой или заглушек много, используют библиотеку testify/mock или gomock, но для большинства случаев рукописная заглушка достаточна.
Тесты с реальной базой данных
Некоторые вещи невозможно проверить без настоящей базы: сложные запросы, поведение транзакций, схема миграций. Имитации базы здесь не помогут — они не воспроизводят диалект SQL и тонкости PostgreSQL.
Для таких тестов используют testcontainers-go: библиотека запускает Docker-контейнер с PostgreSQL прямо во время теста и останавливает его после.
func TestSaveProduct(t *testing.T) {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer container.Terminate(ctx)
// дальше — подключение и тест реального репозитория
}
Таких тестов держат немного: они медленнее юнит-тестов, им нужен Docker. Они проверяют то, что нельзя проверить иначе — реальные sqlc-запросы и миграции.
Как организовать тесты
Работающее правило: чем больше тест зависит от внешних систем, тем выше он в иерархии и тем меньше таких тестов.
- Юнит-тесты — тестируют отдельную функцию или тип с заглушками. Быстрые, их много. Запускаются на каждое сохранение.
- Тесты HTTP-слоя — проверяют обработчики через
httptest. Чуть медленнее, потому что задействован роутинг и сериализация. - Интеграционные тесты — с реальной базой через testcontainers. Медленные, их мало. Запускаются в CI.
Разделять интеграционные тесты удобно через build-тег:
//go:build integration
package repository_test
Тогда go test ./... их пропустит, а go test -tags integration ./... — включит.
Коротко
- Тест в Go — функция
TestXxx(t *testing.T)в файле*_test.go. Запуск:go test ./.... - Table-driven тест: срез кейсов +
t.Run— удобно проверять много входов в одной функции. httptest.NewRequestиhttptest.NewRecorderпозволяют тестировать HTTP-обработчики без сети.- Зависимости подменяют через маленькие интерфейсы, объявленные у потребителя. Заглушки пишут вручную — это несколько строк кода.
- Для тестов с реальной базой используют
testcontainers-go— PostgreSQL в Docker на время теста. - Интеграционные тесты отделяют build-тегом
//go:build integration, чтобы не замедлять обычный прогон.
Что почитать дальше
- Persistence: sqlc и pgx — как организовать работу с базой данных в Go.
- Middleware в Go — цепочки обработчиков HTTP-запросов.
- Обработка ошибок в Go — идиомы и паттерны работы с ошибками.