Что проверяет каждый слой тестов и что не проверяет ни один
«У тебя in-memory хранилище в unit-тестах — какие недостатки, почему не поднял Postgres?» — вопрос с ловушкой: ожидаемый сильный ответ не «поднял бы, но не успел», а «не поднял намеренно в unit-тестах, и вот отдельный слой, где Postgres поднят по-настоящему».
Два слоя, две разные цели
Unit-тесты usecase-слоя — табличные тесты с фейковыми реализациями repository-интерфейсов (fakeOrderRepo, fakeItemRepo — обычные Go-структуры с map внутри, без единой SQL-строки). Проверяют бизнес-правила изолированно:
func TestCreateOrder_InsufficientStock(t *testing.T) {
itemRepo := &fakeItemRepo{
items: map[uuid.UUID]domain.Item{itemID: {ID: itemID, Stock: 5}},
insufficientFor: map[uuid.UUID]bool{itemID: true}, // мок форсирует "не хватило"
}
orderRepo := newFakeOrderRepo()
uc := NewOrderUsecase(orderRepo, itemRepo, /* ... */)
_, err := uc.CreateOrder(ctx, input)
var stockErr domain.InsufficientStockError
if !errors.As(err, &stockErr) {
t.Fatalf("want InsufficientStockError, got %v", err)
}
if len(orderRepo.orders) != 0 {
t.Fatal("order must not be created when stock check fails")
}
}Выполняется за микросекунды, без Docker, гоняется сотнями раз в CI без сетевых издержек. Проверяет: правильно ли ветвится бизнес-логика при конкретном входе, не создаётся ли побочных эффектов при ошибке (orderRepo.orders пуст), правильный ли тип ошибки возвращается.
Чего фейковый repository физически не может проверить:
| Что нужно проверить | Почему in-memory fake не подходит |
|---|---|
UPDATE ... WHERE stock >= qty атомарен как одна SQL-операция |
Fake это просто if stock >= qty { stock -= qty } в Go — синтаксис SQL там не участвует вообще |
SELECT ... FOR UPDATE реально блокирует строку |
В fake нет транзакций, блокировать нечего |
| Два параллельных запроса на одну строку ведут себя так, как задумано | Fake однопоточен по построению теста, гонки негде возникнуть |
| Миграция создаёт правильную схему, FK/CHECK работают | Fake вообще не знает о существовании схемы |
E2E: поднять настоящий Postgres и настоящую Kafka
testcontainers-go/modules/compose загружает тот же docker-compose.yml, что используется для локального запуска и в демо — не отдельный docker-compose.e2e.yml. Причина конкретная: два файла конфигурации для одного стека — источник рассинхрона (порт/топик/переменная окружения поменялись в одном месте и забыты в другом), не подстраховка.
func startStack(t *testing.T) (kitchenURL, establishmentURL string) {
compose, err := tc.NewDockerCompose("../docker-compose.yml")
require.NoError(t, err)
t.Cleanup(func() {
compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveVolumes(true))
})
require.NoError(t, compose.Up(context.Background(),
tc.Wait(true))) // ждёт healthcheck'и всех сервисов, не просто "контейнер стартовал"
return waitForHealthy(t, compose, "kitchen-service"), waitForHealthy(t, compose, "establishment-service")
}Полный сценарий проверяется по-настоящему: POST /orders → строка в outbox той же транзакцией → фоновый relay реально публикует в Kafka → establishment-service реально читает топик и видит заказ с непустыми items[] (регрессия на уже однажды сломанное место — списочные ручки когда-то отдавали пустой список позиций) → серия /advance через реальный HTTP → kitchen consumer реально читает orders.status_updated и применяет переходы → completed.
Отдельными сценариями проверены: недостаточный остаток (409, остаток физически не тронут — проверяется повторным чтением из БД, не из ответа API), отмена с восстановлением остатка и идемпотентной повторной отменой, и DLQ (см. «Transactional outbox и DLQ на практике»).
~90 секунд на весь e2e-сьют. Не гоняется на каждый unit-тест-ран — это отдельный, более медленный уровень с другой целью, не замена unit-тестам и не их дублирование.
Осознанно НЕ протестировано: repository-слой моками
Мокать pgx.Tx/pgxpool.Pool, чтобы написать unit-тест для OrderRepository.Create, даёт ложное чувство защищённости: такой тест проверяет, что Go-код вызвал Exec с ожидаемыми аргументами, но не исполняет реальный SQL — синтаксическая ошибка в самом запросе тестом не ловится вообще, потому что мок никогда не парсит SQL-строку. Единственный способ реально проверить корректность SQL — исполнить его на настоящей БД, а это уже зона e2e. Поэтому repository-слой в проекте проверен либо через e2e (сквозные сценарии), либо вживую при разработке (docker compose up, ручные запросы) — не через unit-тесты с моками БД.
Небезопасные приведения типов — тоже находка, не мелочь
В первой версии e2e-хелперов JSON-ответы разбирались через unchecked type assertions. Если формат ответа сервера случайно изменится (поле переименовано, тип поменялся), тест не падает с понятным сообщением «поле X отсутствует», а паникует посреди прогона с малоинформативным stack trace:
id := resp["id"].(string) // паника, если поле отсутствует или другого типаid, ok := resp["id"].(string)if !ok { t.Fatalf("expected id to be a string, got %T: %v", resp["id"], resp["id"])}Мелкая деталь, но именно такая деталь показывает на собесе разницу между «тест зелёный» и «тест зелёный и информативный при поломке». Разница в поведении — не абстрактная, вот она вживую:
unsafeAssert в реальном тесте (без recover, который здесь добавлен только чтобы показать оба исхода в одной программе) уронил бы весь прогон непойманной паникой; safeAssert возвращает информативное сообщение, которое t.Fatalf покажет как причину падения конкретного теста, не всего пакета.