Опирается на правила: GOTEST-26, GOTEST-27, GOTEST-28, GOTEST-X9, GOTEST-X10 из Go Test Strategy Rules → раздел 8. Пирамида тестов.

Важно знать

  • Три уровня: unit (агрегат без инфраструктуры), интеграционный (Testcontainers + chi + net/http), E2E (build-tag e2e, отдельный CI-этап).
  • Unit — только для чистой доменной логики агрегата: NewOrder(...) → метод → assert.ErrorAs.
  • Интеграционный — через httptest.NewServer(router) + реальный Postgres; testify/mock.Repository здесь запрещён (GOTEST-X9).
  • E2E — не более 5–10 тестов на сервис; реальные Kafka/внешние сервисы допустимы только здесь.
  • testify/mock на Repository-интерфейс допустим исключительно в unit-тесте контроллера (GOTEST-27), не в интеграционном.
  • Реальный Keycloak/JWKS в интеграционном тесте запрещён — только fakeAuthMiddleware (GOTEST-X10).
  • httptest.NewRecorder — для unit-теста контроллера без БД; http.DefaultClient — для интеграционного.

Пирамида — три уровня с разными обязанностями. Попытка покрыть всё интеграционным тестом раздувает время прогона; перенос бизнес-логики в unit без инфраструктуры даёт обратную связь за миллисекунды. Главное правило: инфраструктура участвует только там, где это проверяет реальный сценарий.

Уровень 1. Unit — агрегат без инфраструктуры

GOTEST-26: чистая доменная логика тестируется без Testcontainers, без chi, без HTTP.

Объект теста — агрегат или доменный value object. Вызываем метод напрямую, проверяем возвращаемую ошибку или состояние структуры.

func TestOrder_Cancel_WhenAlreadyCancelled_ReturnsError(t *testing.T) {
    order := &Order{ID: "o1", Status: StatusCancelled}

    err := order.Cancel()

    var domainErr *AlreadyCancelledError
    assert.ErrorAs(t, err, &domainErr)
}

func TestOrder_Confirm_WhenDraft_SetsStatusConfirmed(t *testing.T) {
    order := &Order{ID: "o2", Status: StatusDraft}

    err := order.Confirm()

    require.NoError(t, err)
    assert.Equal(t, StatusConfirmed, order.Status)
}

Что входит в unit-тест агрегата:

  • бизнес-инварианты: переходы состояний, проверки полей;
  • доменные ошибки: возвращаются как типизированные значения (*AlreadyCancelledError, *InsufficientAmountError);
  • граничные значения: нулевая сумма, пустой ID, максимальный лимит.

Что не входит: база данных, HTTP, Testcontainers, pgxpool.

Уровень 2. Unit-тест контроллера без БД

GOTEST-27: проверка сериализации и маршрутизации — через httptest.NewRecorder + реальный chi-роутер + in-memory реализация репозитория.

Это единственное место, где testify/mock или простая in-memory реализация интерфейса репозитория допустима.

type inMemoryOrderRepo struct {
    orders map[string]*order.Order
}

func (r *inMemoryOrderRepo) FindByID(_ context.Context, id string) (*order.Order, error) {
    o, ok := r.orders[id]
    if !ok {
        return nil, order.ErrNotFound
    }
    return o, nil
}

func TestOrderHandler_GetOrder_WhenNotFound_Returns404(t *testing.T) {
    repo := &inMemoryOrderRepo{orders: map[string]*order.Order{}}
    svc := order.NewService(repo, &fixedClock{at: time.Now()}, &seqIDGenerator{})

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)

    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/orders/nonexistent", nil)
    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusNotFound, w.Code)
}

func TestOrderHandler_CreateOrder_WhenInvalidBody_Returns400(t *testing.T) {
    repo := &inMemoryOrderRepo{orders: map[string]*order.Order{}}
    svc := order.NewService(repo, &fixedClock{at: time.Now()}, &seqIDGenerator{})

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)

    w := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodPost, "/orders",
        strings.NewReader(`{"customerId": ""}`))
    req.Header.Set("Content-Type", "application/json")
    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusBadRequest, w.Code)
}

Разница с интеграционным:

  • нет TestMain, нет Testcontainers, нет pgxpool;
  • сервер не поднимается — r.ServeHTTP(w, req) вызывается напрямую;
  • репозиторий — in-memory или testify/mock, не реальный Postgres.

Уровень 3. Интеграционный — Testcontainers + chi + net/http

Основной уровень пирамиды: полный путь от HTTP-запроса до строки в PostgreSQL.

GOTEST-X9: testify/mock на Repository здесь запрещён — тест проверяет реальный путь через sqlc и Postgres.

func TestCreateOrder_Success(t *testing.T) {
    srv, prep := newTestServer(t)
    prep.Clear(t).CreateCustomer(t, "c-sber-01")

    resp, err := http.Post(
        srv.URL+"/orders",
        "application/json",
        strings.NewReader(`{"customerId":"c-sber-01","amount":5000}`),
    )
    require.NoError(t, err)
    defer resp.Body.Close()

    require.Equal(t, http.StatusCreated, resp.StatusCode)

    var body struct {
        OrderID string `json:"orderId"`
        Status  string `json:"status"`
    }
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    assert.Equal(t, "DRAFT", body.Status)
    assert.NotEmpty(t, body.OrderID)
}

func TestCancelOrder_WhenAlreadyCancelled_Returns422(t *testing.T) {
    srv, prep := newTestServer(t)
    prep.Clear(t).
        CreateCustomer(t, "c-sber-02").
        CreateOrder(t, "o-sber-01", "c-sber-02", "CANCELLED")

    req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/orders/o-sber-01", nil)
    resp, err := http.DefaultClient.Do(req)
    require.NoError(t, err)
    defer resp.Body.Close()

    require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)

    var body map[string]any
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    assert.Equal(t, float64(422), body["status"])
}

Инфраструктура теста — TestMain + newTestServer:

var testDSN string

func TestMain(m *testing.M) {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("orders_test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(wait.ForListeningPort("5432/tcp")),
    )
    if err != nil {
        log.Fatalf("start postgres: %v", err)
    }
    testDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")
    applyMigrations(testDSN)

    code := m.Run()
    _ = pg.Terminate(ctx)
    os.Exit(code)
}

func newTestServer(t *testing.T) (*httptest.Server, *OrderDatabasePreparer) {
    t.Helper()
    pool, err := pgxpool.New(context.Background(), testDSN)
    require.NoError(t, err)
    t.Cleanup(pool.Close)

    q := db.New(pool)
    repo := order.NewRepository(q)
    clock := &fixedClock{at: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)}
    ids := &seqIDGenerator{}
    svc := order.NewService(repo, clock, ids)

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)
    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    return srv, &OrderDatabasePreparer{pool: pool}
}

Уровень 4. E2E — build-tag e2e, отдельный CI-этап

GOTEST-28: E2E-тесты запускаются только с тегом e2e — не попадают в обычный go test ./....

//go:build e2e

package e2e_test

import (
    "net/http"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestCreateProduct_E2E(t *testing.T) {
    baseURL := os.Getenv("E2E_BASE_URL")
    require.NotEmpty(t, baseURL, "E2E_BASE_URL must be set")

    resp, err := http.Post(
        baseURL+"/products",
        "application/json",
        strings.NewReader(`{"name":"Товар Сбер","price":1500}`),
    )
    require.NoError(t, err)
    defer resp.Body.Close()

    assert.Equal(t, http.StatusCreated, resp.StatusCode)
}

Особенности E2E:

  • реальное окружение (staging или отдельный стенд), реальные Kafka/Redis/внешние сервисы;
  • не более 5–10 тестов на сервис — дымовая проверка критических путей;
  • отдельная CI-джоба: go test -tags=e2e ./e2e/...;
  • GOTEST-X10: Keycloak/JWKS в интеграционном тесте запрещён; если нужна проверка авторизации — fakeAuthMiddleware (см. Авторизация в тестах).

Авторизация на каждом уровне

GOTEST-X10: реальный Keycloak в интеграционном тесте не поднимается. Вместо этого — fakeAuthMiddleware, который прокидывает фейковый Principal в контекст:

func TestGetOrder_WhenForbiddenRole_Returns403(t *testing.T) {
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    r := chi.NewRouter()
    r.Use(fakeAuthMiddleware(Principal{ID: "u-1", Role: "viewer"}))
    orderhttp.Mount(r, order.NewService(order.NewRepository(db.New(pool)), &fixedClock{}, &seqIDGenerator{}))
    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/orders/o-1", nil)
    resp, _ := http.DefaultClient.Do(req)
    defer resp.Body.Close()

    assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}

Тест на 403 обязателен — отключение auth-middleware маскирует баги авторизации (GOTEST-X11).

Где применять каждый уровень

Что тестируемУровеньИнструменты
Инварианты агрегата, переходы состоянийUnittestify/assert, testify/require
Сериализация JSON, маршрутизация chiUnit контроллераhttptest.NewRecorder, in-memory repo
UseCase — HTTP → Postgres → ответИнтеграционныйTestcontainers, httptest.NewServer, http.DefaultClient
Outbox-событие после командыИнтеграционныйDatabasePreparer.FindOutboxEvents
Внешний HTTP-клиент (платёж, каталог)Интеграционныйhttptest.NewServer в роли заглушки
Kafka/Redis-интеграция с реальным брокеромE2Ebuild-tag e2e, отдельный CI-этап
Сквозной сценарий через все сервисыE2Ebuild-tag e2e, staging-окружение

Что запрещено

АнтипаттернПравилоЧто взамен
testify/mock на Repository в интеграционном тестеGOTEST-X9реальный Postgres через Testcontainers
Реальный Keycloak/JWKS в интеграционном тестеGOTEST-X10fakeAuthMiddleware с фейковым Principal
testify/mock на Repository нигде кроме unit-теста контроллераGOTEST-X9in-memory реализация интерфейса
E2E-тест без build-tag e2eGOTEST-28//go:build e2e в начале файла
Kafka/Redis в Testcontainers в базовом интеграционном тестеGOTEST-X7Outbox-таблица, NoopCache, build-tag integration
httptest.NewRecorder в интеграционном тесте (без реального HTTP-стека)GOTEST-27httptest.NewServer + http.DefaultClient
Один «мега-тест» на весь lifecycle заказаGOTEST-3один тест — один сценарий

Куда дальше

  • Базовые правила (Go) — GOTEST-1…GOTEST-3: детерминизм, синхронность, AAA-структура.
  • Инфраструктура теста — TestMain и Testcontainers — GOTEST-4…GOTEST-7: как поднять Postgres и собрать newTestServer.
  • DatabasePreparer (Go) — GOTEST-11…GOTEST-14: fluent setup БД, порядок TRUNCATE.
  • Авторизация в тестах (Go) — GOTEST-29…GOTEST-30: fakeAuthMiddleware и хелперы роли.
  • Что такое Use Case Pattern — контекст методологии, BR-коды, спецификации.