TL;DR
Я наткнулся на подход к функциональным тестам, который меня по-настоящему удивил. Тесты в нём вообще не знают, что внутри Spring. Они стучатся в реально поднятый сервис по HTTP, как обычный клиент. Гоняются параллельно и проходят за секунды. Я разбирал это на код-ревью, посмотрел и сначала прифигел. А потом не стал переходить на него сам. Но кое-что забрал себе. Дальше давайте посмотрим на оба подхода, сравним их, разберёмся где какой уместен, и я покажу три вещи, которые подсмотрел и притащил в свой Spring
Сначала немного разогрева

Не буду в сотый раз рассказывать, почему тесты это важно, и рисовать пирамиду тестирования. Пирамида (куча юнитов снизу, чуть-чуть e2e сверху) придумана в те времена, когда поднять базу в тесте было целым приключением.
Сейчас база поднимается в докере за пару секунд, и картинка поменялась. Кент Доддс описал новую модель как Testing Trophy. Это уже не пирамида, а кубок: узкий верх, потом широкий слой интеграционных тестов, тонкая ножка юнитов и широкое основание из статанализа.
Что поменялось по сравнению с пирамидой
Снизу теперь не тесты, а статический анализ. У нас в проекте это Checkstyle, PMD, SpotBugs и pg-index-health. Они ловят кучу ошибок ещё до того, как запустится хоть один тест. Самый дешёвый тест это тот, который не пришлось писать, потому что линтер уже не дал наступить на грабли. Про то, как я держу правила для проекта на трёх уровнях (включая статические анализаторы) и зачем это всё, я подробно рассказывал в отдельной статье.
Центр тяжести сместился на интеграционные тесты. Юнит с моками часто врёт: ты замокал ровно то, что хотел проверить, тест зеленеет, а приложение при этом может быть мёртвым. У нас в команде вообще договорились писать только функциональные тесты, потому что они гоняют весь стек целиком: HTTP, сервис, база, Kafka. И ловят реальные косяки на стыках.
И вот когда вся твоя жизнь это функциональные тесты, выясняется, что писать их можно двумя очень разными способами. Я лет десять писал первым способом и был уверен, что так и надо. Пока не открыл чужой репозиторий.
Как пишем мы
Наш обычный функциональный тест выглядит так. Поднимаем приложение прямо внутри тестового JVM, рядом Testcontainers с Postgres и Kafka:
@SpringBootTest(webEnvironment = RANDOM_PORT, classes = { Application.class, /* ... */ }) @ActiveProfiles("test") @AutoConfigureWireMock(port = 0) @Import({ EnableTestcontainersPostgreSql.class, EnableTestcontainersKafka.class }) public abstract class BaseTest { @Autowired protected SellerOrderDao sellerOrderDao; // лезем в базу напрямую @MockitoBean protected KafkaMessageSender<?, ?> sender; // подменяем бины внутри контекста @MockitoSpyBean protected ApiSellerServiceClient apiClient; // ...и ещё пара десятков полей }
Дальше тест готовит данные через DAO, дёргает контроллер и проверяет результат через репозиторий:
var dropEntity = dropOffPointDao.save(dropOffPoint); var rs = requests.getReturnDropOffPointByUuid(dropEntity.getUuid()); assertThat(rs) .returns(HttpStatus.OK, ResponseEntity::getStatusCode) .extracting(r -> r.getBody().getPayload()) .returns(dropEntity.getUuid(), ReturnDropOffPointDto::getUuid);
Ассерты пишем на AssertJ, покрытие считаем через JaCoCo. Базовую механику (JUnit 5, Mockito, AssertJ, JaCoCo, как это всё подключать и считать покрытие) я разбирал в отдельном гайде, тут повторяться не буду.
Это нормальный рабочий подход. Он проверяет настоящую бизнес-логику, ходит в реальную базу, гоняет мапперы и Hibernate. На тяжёлой доменке (заказы с десятком состояний, outbox, штрафы, рейтинги) он себя оправдывает.
Но у него есть цена, которую замечаешь не сразу. Тест тут не клиент сервиса. Он его часть. Живёт в том же JVM, дёргает его бины, подменяет ему кишки, читает его базу напрямую. К чему это приводит на практике, покажу на трёх историях из последнего месяца.
История первая: дедлок на ровном месте
Между тестами надо чистить базу. Обычно это TRUNCATE по общим таблицам:
jdbcTemplate.execute("TRUNCATE TABLE seller_order, fbs_sku, /* ...30 таблиц... */ CASCADE");
И вот в чём засада. Фоновые async-задачи от предыдущего теста ещё дорабатывают и держат на этих таблицах AccessShareLock. А TRUNCATE хочет AccessExclusiveLock. Получаем дедлок и красный билд на пустом месте. Чинили так:
// фоновые задачи держат блокировку, и TRUNCATE ловит дедлок. // это быстро проходит, поэтому ретраим с бэкоффом, а lock_timeout не даёт зависнуть for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { jdbcTemplate.execute("SET LOCAL lock_timeout = '5s'"); jdbcTemplate.execute("TRUNCATE TABLE " + tables + " CASCADE"); return; } catch (PessimisticLockingFailureException e) { if (attempt == MAX_ATTEMPTS) throw e; sleepBackoff(); } }
То есть у нас есть отдельный цикл с ретраями просто чтобы почистить базу между тестами. Звучит странно, если остановиться и подумать.
История вторая: гонка async-задачи и синхронного хендлера
Тест отмены заказа флакал. Иногда статус успевал стать нужным, иногда нет. Потому что в коде есть async-ретраер, а тест проверял результат сразу после синхронного handle(). То есть тест полагался на внутренний тайминг системы, в которую сам же и встроен.
История третья: WireMock и счётчики вызовов
Ещё пачка нестабильных тестов из-за того, что WireMock-стабы и счётчики вызовов зависели от порядка и параллельности. Опять тест слишком много знает про внутренности.
Если присмотреться, все три беды от одного. Тест сросся с системой. Он чистит её таблицы, знает её тайминги, считает её внутренние вызовы. Запомним это.
А потом я открыл чужой репозиторий
Это был тонкий сервис-прокси. И тесты в нём устроены совсем по-другому. Ни одного @SpringBootTest. Вообще ни одного упоминания Spring в тестах.
Сервис там поднимают как обычный процесс (docker compose up плюс запуск jar), а тест стучится в него снаружи, как любой HTTP-клиент:
class GetStatisticTest { // ни наследования, ни аннотаций, ни DI @Test void test() { String testRunId = Generator.newTestRunId(); // уникальный прогон long shopId = Generator.newLongId(); MOCK.when(request().withMethod("GET").withPath("/api/internal/.../" + shopId)) .respond(response().withStatusCode(200).withBody("...")); // мок на HTTP-границе HTTP.get("http://localhost:9102/api/.../product/shop/{shopId}") .header("X-Request-Id", testRunId) .routeParam("shopId", shopId) .asString(); await().atMost(ofSeconds(5)).untilAsserted(() -> { // ждём видимый результат var stat = HTTP.get(".../request-statistic").queryString("sellerId", sellerId).asString(); assertThatJson(stat.getBody()).isEqualTo("[{\"requestCount\": 1}]"); }); } }
Что тут устроено принципиально иначе.
Тест это клиент, а не часть сервиса. Он не дёргает бины и не лезет в базу через DAO. Он знает ровно то, что знает любой внешний потребитель: URL, заголовки, JSON. Сам подход формулируется одной фразой: интеграционный тест должен вести себя как клиент системы, а не как её кусок.
Зависимости мокаются на HTTP-границе через MockServer (это отдельный процесс), а не через Mockito внутри контекста.
Изоляция держится на testRunId, а не на чистке базы. Каждый прогон уникальный, поэтому тесты не топчут друг друга и спокойно идут параллельно, в случайном порядке. Никакого TRUNCATE-ада из первой истории, потому что чистить общую базу тут просто не нужно.
Ассерты это сравнение JSON с заранее сохранёнными файлами (json-unit), а сценарии задаются через CSV. Добавить роут это строчка в конфиге плюс строчка в CSV, тест сам её подхватывает.
И ждут они видимый результат (await на ручке статистики), а не внутренний тайминг. Вторая история тут в принципе невозможна.
А теперь то, что добило мой скепсис. Первый вопрос к такому подходу обычно: а покрытие вы как меряете, если в код не лезете? Оказалось, цепляют JaCoCo-агент к живому процессу приложения, гоняют по нему свои внешние тесты, а потом склеивают дамп агента с юнитами в один отчёт.

То есть «нет покрытия» это миф. Покрытие снимается с реально работающего приложения. Если подумать, это даже честнее, чем инструментированный тестовый JVM.
Сравнение и честный выбор

Почему я остался на классике
Если честно, по пунктам.
Навигация и дебаг. В нашем подходе я ставлю брейкпоинт в тесте и проваливаюсь через весь стек до нужной строки в сервисе. В black-box между тестом и кодом сетевая граница, провалиться внутрь нельзя, дебажишь два процесса сразу. На тяжёлой доменке это заметно мешает.
Сырой JSON вместо DTO. Для прокси, где надо отдать ровно то, что вернул сосед, файлы с эталонным JSON это идеально. А для доменки с десятками типизированных полей и состояний возиться со строками JSON больно.
Это хорошо ложится на небольшие сервисы, но не на монолиты. Наш сервис давно не тонкий прокси. Чем больше внутри механики (шедулеры, время, фоновые задачи), тем больше всего этого надо выносить в API ради тестируемости. А это лезет в продакшн-код.
Корнер-кейсы и комбинаторику всё равно гоняют юнитами. А у нас договорённость писать только функциональные. Пришлось бы заводить ещё один слой тестов.
Плюс банально: мне проще ориентироваться в наших тестах. И это нормальная причина. Подход должен ложиться на сервис и на голову команды, а не наоборот.
Что я всё-таки забрал

А вот это главная мысль. Ценность black-box не в собственных HTTP-клиентах. Она в одном ограничении: тест ведёт себя как клиент, а не как часть системы. И это ограничение можно затащить в обычный @SpringBootTest, не выключая Spring. Вернёмся к трём историям:
Идея из black-box |
Какую боль снимает |
Как забрать, оставаясь на Spring |
|---|---|---|
Изоляция через уникальный ID вместо чистки общей базы |
дедлоки |
уникальные идентификаторы на тест вместо |
Тест это клиент, а не часть системы |
разрастание контекст-кэша, хрупкость |
не подменять бины внутри контекста ( |
Ждать видимый результат, а не внутренний тайминг |
гонка async-задач (история 2) |
|
Тут интересный момент. Второй пункт у нас уже записан в правилах тестирования: @MockBean запрещён, потому что разносит контекст-кэш. Мы дошли до части этих принципов сами, через свою боль. А black-box приходит к ним через дисциплину подхода, поэтому там таких болей просто не возникает.
Ещё одно наблюдение, которое меня зацепило. Компромиссы black-box оказываются полезными. Чтобы тест мог вести себя как клиент, тебе приходится прокидывать сквозной
X-Request-Id, выносить работу со временем в API, давать шедулерам ручки. То есть тесты начинают давить на архитектуру в правильную сторону. Плохой код просто тяжело так тестировать.
Итог
Так когда что использовать.
Если сервис тонкий, прокси или BFF, и главное в нём это поведение на границе, берите black-box. Секунды вместо минут, параллельность из коробки, тесты ведут себя как настоящие клиенты.
Если доменка тяжёлая, с состояниями, JPA и фоновыми задачами, оставайтесь на классике с @SpringBootTest. Навигация, дебаг, типизированные данные того стоят.
Но это не «или-или». Я не поменял подход. Я поменял взгляд на свои тесты. Теперь каждый раз, когда тест начинает чистить чужие таблицы, считать внутренние вызовы или полагаться на тайминг, я как будто слышу: ты ведёшь себя как часть системы, а должен как клиент. И флака становится меньше. Без единой строчки про Docker Compose.
Самый полезный тест это тот, который ведёт себя как ваш пользователь. А живёт он внутри JVM или снаружи по HTTP, это уже про размер сервиса и вкус команды.
Что бы я сделал по-другому
Завёл бы изоляцию через уникальные ID с самого начала, а не приехал бы к ней через цикл ретраев на TRUNCATE.
И раньше сформулировал бы правило: тест ждёт видимый результат, а не внутренний тайминг. Это сэкономило бы недели на разборе нестабильных тестов.
P.S. Сам подход и слайды, на которые я опираюсь, это работа коллеги. Спасибо ему за репозиторий, в который было не стыдно залезть.
spirit1984
В критериях про "Скорость" сказано, что у классики (SpringBootTest) минуты, тогда как у BlackBox - секунды. Это как? Само приложение такое тяжеловесное, что ему нужна пара минут? Тогда в докере будет так же. Или это зависимости поднять так много занимает? Тогда в docker compose поднять тестовые базы тоже будет так же.
igoresha_s Автор
Старт приложения и поднятие зависимостей это разовая штука, в обоих подходах одинаковая
Дело не в тяжести приложения. В классике контекст Spring часто перетряхивается прямо посреди прогона: разные конфигурации тестов, @MockBean, @DirtiesContext и приложение поднимается заново по нескольку раз за сюит. А JUnit всё это обычно гоняет в один поток, так что переподнятия складываются в те самые «минуты»
В BlackBox тесты идут параллельно: сервис поднят один раз, и тесты молотят его по HTTP пачкой одновременно, как обычные внешние клиенты. Им не нужен общий Spring-контекст, нечему мешать друг другу , поэтому распараллеливание честное и почти линейное отсюда и секунды. В классике так не разогнаться: общий контекст и общая БД заставляют тесты толкаться и сваливаться в последовательный прогон