← назад к разделу

Если вы раньше писали тесты на других языках, вы привыкли устанавливать тестовый фреймворк, настраивать раннер и подключать библиотеку для моков. В 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 — идиомы и паттерны работы с ошибками.