MBL
Go / avito / Тестовое задание / Что проверяет каждый слой тестов и что не проверяет ни один
Go средний

Что проверяет каждый слой тестов и что не проверяет ни один

avitotestingpostgrestestcontainers

«У тебя 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"])}

Мелкая деталь, но именно такая деталь показывает на собесе разницу между «тест зелёный» и «тест зелёный и информативный при поломке». Разница в поведении — не абстрактная, вот она вживую:

на play.golang.org

unsafeAssert в реальном тесте (без recover, который здесь добавлен только чтобы показать оба исхода в одной программе) уронил бы весь прогон непойманной паникой; safeAssert возвращает информативное сообщение, которое t.Fatalf покажет как причину падения конкретного теста, не всего пакета.

Самопроверка 0 / 3
Могу назвать, что именно ловит unit-тест usecase, а что — только e2e
Знаю, почему repository-слой не тестируется моками "ради покрытия"
Понимаю, зачем e2e поднимает тот же docker-compose.yml, а не отдельный
Как усвоено?