Опирается на правила:
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).
Где применять каждый уровень
| Что тестируем | Уровень | Инструменты |
|---|---|---|
| Инварианты агрегата, переходы состояний | Unit | testify/assert, testify/require |
| Сериализация JSON, маршрутизация chi | Unit контроллера | httptest.NewRecorder, in-memory repo |
| UseCase — HTTP → Postgres → ответ | Интеграционный | Testcontainers, httptest.NewServer, http.DefaultClient |
| Outbox-событие после команды | Интеграционный | DatabasePreparer.FindOutboxEvents |
| Внешний HTTP-клиент (платёж, каталог) | Интеграционный | httptest.NewServer в роли заглушки |
| Kafka/Redis-интеграция с реальным брокером | E2E | build-tag e2e, отдельный CI-этап |
| Сквозной сценарий через все сервисы | E2E | build-tag e2e, staging-окружение |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
testify/mock на Repository в интеграционном тесте | GOTEST-X9 | реальный Postgres через Testcontainers |
| Реальный Keycloak/JWKS в интеграционном тесте | GOTEST-X10 | fakeAuthMiddleware с фейковым Principal |
testify/mock на Repository нигде кроме unit-теста контроллера | GOTEST-X9 | in-memory реализация интерфейса |
E2E-тест без build-tag e2e | GOTEST-28 | //go:build e2e в начале файла |
| Kafka/Redis в Testcontainers в базовом интеграционном тесте | GOTEST-X7 | Outbox-таблица, NoopCache, build-tag integration |
httptest.NewRecorder в интеграционном тесте (без реального HTTP-стека) | GOTEST-27 | httptest.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-коды, спецификации.