Тестирование в Go встроено в язык и инструменты: пакет testing в стандартной библиотеке, команда go test, никакого внешнего раннера. А подменяемость зависимостей даёт не контейнер, а интерфейсы — маленькие, объявленные там, где используются. Это делает тесты быстрыми и явными.

Пакет testing и table-driven

Тест — функция TestXxx(t *testing.T). Идиоматичная форма — table-driven: набор случаев в срезе, один цикл.

func TestMapError(t *testing.T) {
    cases := []struct {
        name string
        err  error
        want int
    }{
        {"not found", ErrProductNotFound, http.StatusNotFound},
        {"forbidden", ErrForbidden, http.StatusForbidden},
        {"other", 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 даёт каждому случаю имя и независимый прогон. Это нижний, самый широкий уровень пирамиды — быстрые тесты чистой логики.

httptest для обработчиков

HTTP-слой тестируют без сети — пакетом httptest. NewRecorder ловит ответ обработчика, NewServer поднимает реальный сервер в памяти для сквозных проверок.

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("status: got %d, want 200", rec.Code)
    }
}

Так проверяется весь конвейер — middleware, роутинг, обработчик — на настоящем http.Handler.

Подмена через интерфейсы

В Go зависимости подменяют не контейнером, а интерфейсами. Ключевая идиома: интерфейс объявляет потребитель, и он маленький — ровно те методы, что нужны.

type productSaver interface {
    Save(ctx context.Context, cmd CreateProductCommand) (Product, error)
}

type CreateProductHandler struct {
    repo productSaver
}

Теперь в тесте вместо реального репозитория подставляют заглушку, реализующую productSaver:

type stubSaver struct{ saved Product }

func (s *stubSaver) Save(ctx context.Context, cmd CreateProductCommand) (Product, error) {
    return s.saved, nil
}

Handler тестируется в изоляции, без базы — быстро. Маленькие интерфейсы у потребителя — то, что делает Go-код тестируемым без фреймворка моков.

Реальная база: testcontainers

Тесты, которым нужна база, бьют в настоящий PostgreSQL (диалект и SQL должны быть теми же), поднятый на время теста через testcontainers. Так проверяются sqlc-запросы и миграции — на реальной базе, а не на имитации. Таких тестов держат немного: они медленнее и стоят выше в пирамиде.

Где это в UCP

Интерфейсы у потребителя задают пирамиду: быстрые тесты Handler-ов и домена с заглушками — снизу, тесты обработчиков через httptest — посередине, немного сквозных с testcontainers — сверху. Логику не гоняют через HTTP без нужды, HTTP-слой не дублируют. Это та же стратегия, что в Spring-биндинге со слайс-тестами и TestContainers, только стандартной библиотекой Go и маленькими интерфейсами. Сервис, который так тестируется, продукт-инженер меняет без страха — go test даёт ответ за секунды.