ФАЗА 20. КАЧЕСТВО Тестирование Go

Обязательно

Тесты, которые ловят изменение поведения

testing · httptest · Testcontainers

Табличные unit-тесты, HTTP без сети, подмена интерфейсов и реальная база там, где важен SQL.

1. Таблица задаёт вход и ожидание

1 / 5

Table-driven тест перечисляет границы одним циклом. Имя случая объясняет сценарий, t.Run изолирует отчёт и cleanup.

  • Проверяются норма, граница и ошибка.
  • Ожидание сравнивается с фактическим результатом.
  • t.Parallel используется только без общего состояния.
Test casesnormal: 2 + 3 = 5zero: 0 + 0 = 0negative: -2 + 1 = -1t.Run(name)same assertionClear report

2. httptest вызывает handler без порта

2 / 5

Request и ResponseRecorder проверяют статус, тело и заголовки через настоящий HTTP handler. Внешний сервер не нужен.

  • Middleware входит в проверяемую цепочку.
  • Ошибка JSON сравнивается по структуре.
  • Сетевой клиент тестируется через httptest.Server.
httptest.RequestPOST /ordersReal handlermiddleware includedRecorder201 + JSON

3. Интерфейс отделяет правило от инфраструктуры

3 / 5

Сервис зависит от маленького интерфейса репозитория или часов. Unit-тест передаёт fake с предсказуемым ответом.

  • Интерфейс объявляется у потребителя.
  • Stub возвращает данные, fake меняет состояние.
  • Проверка не дублирует реализацию.
Order servicedomain ruleRepository interfaceTest fakePostgres adapter

4. SQL проверяется на настоящей СУБД

4 / 5

Testcontainers поднимает PostgreSQL и применяет миграции. Транзакция с rollback изолирует данные каждого теста.

  • Запрос, constraint и миграция проверяются вместе.
  • Общий контейнер не означает общие данные.
  • t.Cleanup закрывает pool и контейнер.
Repository testtx + rollbackTestcontainersmigrations appliedPostgreSQLreal SQL

5. Набор остаётся быстрым и детерминированным

5 / 5

go test -race ищет гонки, -shuffle меняет порядок, время и случайность внедряются. Тест не зависит от внешней сети и соседнего случая.

  • Ошибка сравнивается через errors.Is/As.
  • Флаки расследуют, а не перезапускают вслепую.
  • Покрытие дополняет проверку смысла.
go testfast unitsreal integration-raceshared stategoroutines-shuffleorder independentrepeatable

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

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

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

Тесты с реальной базой данных

Некоторые вещи невозможно проверить без настоящей базы: сложные запросы, поведение транзакций, схема миграций. Имитации базы здесь не помогут — они не воспроизводят диалект 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)

    // дальше — подключение и тест реального репозитория
}

Для PostgreSQL у testcontainers есть готовый модуль, и он короче общего варианта и надёжнее: postgres.Run(ctx, "postgres:16", postgres.WithDatabase("testdb"), postgres.WithPassword("test"), postgres.BasicWaitStrategies()) ждёт не открытия порта, а строки «ready to accept connections» во второй раз — образ PostgreSQL при первом старте поднимается дважды, и ожидание по порту иногда пускает тест раньше времени. Строку подключения даёт ctr.ConnectionString(ctx, "sslmode=disable"), а testcontainers.CleanupContainer(t, ctr) останавливает контейнер даже при panic в тесте.

Таких тестов держат немного: они медленнее юнит-тестов, им нужен Docker. Они проверяют то, что нельзя проверить иначе — реальные sqlc-запросы и миграции.

Как организовать тесты

Работающее правило: чем больше тест зависит от внешних систем, тем выше он в иерархии и тем меньше таких тестов.

  • Юнит-тесты — тестируют отдельную функцию или тип с заглушками. Быстрые, их много. Запускаются на каждое сохранение.
  • Тесты HTTP-слоя — проверяют обработчики через httptest. Чуть медленнее, потому что задействован роутинг и сериализация.
  • Интеграционные тесты — с реальной базой через testcontainers. Медленные, их мало. Запускаются в CI.

Разделять интеграционные тесты удобно через build-тег:

//go:build integration

package repository_test

Тогда go test ./... их пропустит, а go test -tags integration ./... — включит.

Дополнительно: при первом чтении можно пропустить

Глубже: флаги и приёмы, без которых тесты в Go неполныерасширенное

go test -race ./... включает детектор гонок — для сервиса с горутинами это обязательный режим в CI, а не опция: гонка в обработчике не падает в обычном прогоне и годами живёт в проде. t.Parallel() в начале теста и подтеста пускает их параллельно и режет время прогона в разы; с Go 1.22 переменная цикла в табличном тесте создаётся заново на каждой итерации, так что старая ловушка tc := tc больше не нужна. t.Cleanup(func(){...}) регистрирует уборку, которая выполнится после теста и всех его подтестов, — так закрывают пул и удаляют временные таблицы. Один тест запускают через go test -run 'TestMapError/not_found' ./internal/order/, а -count=1 отключает кэш результатов, когда тест зависит от внешнего состояния.

Коротко

  • Тест в 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, чтобы не замедлять обычный прогон.
  • В CI тесты гоняют с -race; t.Parallel() ускоряет прогон, t.Cleanup убирает за тестом, -run и -count=1 управляют запуском.

Что пощупать

Тесты каталога в практикуме remodov/marketplace-system-go трёх видов: чистые тесты правил без базы, HTTP-тесты через httptest на настоящем PostgreSQL с общим TestMain, и тест кэша, который считает обращения к базе обёрткой над интерфейсом Store.

Код: rules_test.go, api_test.go, cache_test.go.

Сделаем сами

Ветка step-04-domain-rules.

Что почитать дальше