← назад к разделу

Чем больше кода, тем труднее понять, каких тестов писать больше, каких меньше и где проходит граница между уровнями. Пирамида тестов — простая модель, которая расставляет всё по местам.

пирамида: много быстрых внизу перевёрнутая: много медленных наверху ширина уровня = сколько тестов число × время одного = прогон уровня e2e12 тестовunit800 тестов e2e800 тестовunit12 тестов integration 120 тестов 120 × 2 с = 4 мин ловит поломку в любом месте пути ловит поломку в связке классов ловит поломку в одном классе 12 × 40 с = 8 мин800 × 5 мс = 4 скрасный тест сразу показывает, какой класс сломан 800 × 40 с = 8,9 часа12 × 5 мс = 0,06 скрасный тест говорит лишь «что-то на пути сломано» полный прогон в один поток, шкала — рабочий день 8 часов 0 4 ч 8 ч 12 минут≈ 12 минут — помещается в каждый коммит ≈ 9 часов — прогон не влезает в рабочий день

Ширина уровня — сколько тестов, справа — во что обходится один. Чем выше уровень, тем больше один тест ловит и тем дороже стоит. Перевёрнутая пирамида проверяет не больше: те же правила, но прогон из минут превращается в часы, а падение перестаёт показывать, какой класс сломан.

Обязательно

Зачем нужна пирамида

Без модели команды часто пишут либо только unit-тесты (не замечают проблем интеграции), либо только e2e-тесты (медленная обратная связь, хрупкие сьюты). Оба крайних случая дороги в поддержке.

Пирамида говорит: у основания — много быстрых изолированных тестов, на вершине — мало медленных сквозных. Чем выше уровень, тем дороже запуск и сложнее диагностика. Поэтому вверх идём только за тем, что нижний уровень проверить не может.

Пирамида — модель, а не закон

Пирамида отвечает на вопрос «каких тестов сколько» одной формой, и в этом её польза. Но у неё есть границы применимости, и знать их важнее, чем саму форму, — иначе команда начинает подгонять тесты под картинку.

Причина простая: пирамида описывает цену, а не истину. Она говорит: дорогой тест должен быть редким. Из чего следует форма, зависит от того, где в вашем сервисе живёт сложность.

Кубок (testing trophy). Если сервис — это в основном приём запроса, проверка, запрос в базу и ответ, то модульных тестов честно нечего писать: в классах нет ветвлений, вся суть в SQL и в маппинге. Попытка набрать «много unit» даёт тесты на заглушках, которые проверяют, что мок вызвали, — они зелёные всегда и не ловят ничего. Правильная форма здесь другая: широкий слой интеграционных тестов посередине, немного модульных на те места, где есть настоящие правила, и немного сквозных. Такую форму называют кубком, и для типичного сервиса работы с данными она честнее пирамиды.

Песочные часы. Много модульных, мало интеграционных, много сквозных. Обычно это не выбор, а симптом: интеграционные тесты писать неудобно (нет контейнеров, база поднимается вручную), и команда закрывает дыру сквозными. Лечится не переписыванием тестов, а тем, что делает интеграционные дешёвыми, — об этом статья про интеграционные тесты.

Перевёрнутая пирамида (много сквозных, мало модульных) — почти всегда беда: прогон в часы, мигающие тесты, никто не знает, что именно сломалось. Единственное оправдание — сервис, у которого своей логики нет вовсе, а есть только склейка чужих вызовов.

Что остаётся верным при любой форме и что стоит унести вместо картинки:

  • обратная связь по самой частой ошибке измеряется секундами, а не минутами;
  • дорогой тест пишут тогда, когда дешёвый не может проверить то же самое, — а не «для надёжности» вдобавок;
  • один сценарий проверяется на одном уровне: дублирование одного правила в модульном, интеграционном и сквозном тесте означает три красных теста на одну правку.

Что на пирамиде не лежит вообще. Пирамида — про функциональные тесты, и попытка разместить на ней всё остальное сбивает с толку. Рядом с ней живут отдельные оси: нагрузочные тесты (отвечают не «работает ли», а «сколько держит», требуют отдельного стенда и гоняются по расписанию, а не на коммит), контрактные тесты (проверяют договор между двумя сервисами, а не поведение одного), тесты миграций, проверки безопасности и сканеры зависимостей. У каждой из этих осей своя частота и своё место в конвейере, и ни одна не является «уровнем» пирамиды.

Три слоя

УровеньЧто проверяетСкорость
Unitодин класс / функция, без внешних зависимостеймиллисекунды
Integrationнесколько компонентов + реальная инфраструктурасекунды на тест (контейнер стартует один раз на прогон)
E2eполный путь от UI / API до БДдесятки секунд

Unit-тесты: изоляция и скорость

Unit-тест проверяет один класс в полной изоляции — без Spring-контекста, без базы, без сети. Зависимости заменяются заглушками.

Слово «заглушка» здесь собирательное, и за ним прячутся пять разных вещей, которые стоит различать с самого начала — иначе разговор в команде превращается в спор о словах. Пустышка (dummy) передаётся только чтобы заполнить аргумент и никогда не вызывается. Стаб (stub) отвечает заранее заданным значением на заданный вопрос: «курс равен 90». Фейк (fake) — работающая упрощённая реализация: репозиторий на HashMap вместо базы. Шпион (spy) запоминает, как его вызывали, и позволяет это проверить после. Мок (mock) — заглушка с заранее заданными ожиданиями: тест падает, если вызов был не таким, как условлено. Разница между стабом и моком практическая: стаб проверяет результат («посчитало верно»), мок проверяет факт вызова («письмо отправлено»), и путаница между ними даёт тесты, которые ломаются при любом изменении кода. Подробный разбор с примерами и границами — в статье про моки и внешние системы.

Короткая формула: один тест — один сценарий поведения. Тест на пять сценариев подводит дважды: первый упавший assert прячет остальные, и по имени теста уже не понять, что именно сломалось.

class DiscountServiceTest {

    private final DiscountService service = new DiscountService();

    @Test
    void appliesDiscountWhenTotalExceedsThreshold() {
        var total = service.apply(new Money(1000), CustomerTier.GOLD);
        assertThat(total).isEqualTo(new Money(900));
    }

    @Test
    void noDiscountBelowThreshold() {
        var total = service.apply(new Money(500), CustomerTier.GOLD);
        assertThat(total).isEqualTo(new Money(500));
    }
}

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

Integration-тесты: реальная инфраструктура

Integration-тест поднимает часть (или весь) Spring-контекст и работает с реальными зависимостями — базой данных, брокером сообщений, внешним HTTP-сервисом.

База в примере ниже — не встроенная H2, а настоящий PostgreSQL: контейнер с ним поднимает общий базовый класс, чтобы не платить стартом в каждом тесте.

@SpringBootTest
@Transactional
class OrderRepositoryIT extends PostgresTestBase {

    @Autowired
    OrderRepository repository;

    @Autowired
    EntityManager em;

    @Test
    void savesAndFindsOrder() {
        var order = repository.save(new Order(CustomerId.of("c-1"), List.of()));

        em.flush();
        em.clear();

        assertThat(repository.findById(order.id())).isPresent();
    }
}

flush() и clear() в середине — не украшение, а разница между настоящей проверкой и самообманом. Без них save только кладёт объект в контекст персистентности, а findById достаёт его же оттуда: ни INSERT, ни SELECT до базы не доходят, и тест остаётся зелёным даже при перепутанных колонках и неверном имени таблицы. flush() отправляет запрос в базу, clear() очищает кеш — дальше поиск действительно читает то, что записалось.

Реальную базу в тестах поднимает Testcontainers — PostgreSQL (или другой движок) в Docker-контейнере, который останавливается после сьюта. Конфигурация Spring-тестов подробнее описана в разделе Тестирование в Spring, а сам контейнер — в интеграционных тестах.

Integration-тесты медленнее unit, поэтому их пишут на критические пути: репозитории, обработчики команд, HTTP-клиенты к внешним сервисам.

E2e-тесты: полный путь запроса

E2e-тест проходит весь путь: HTTP-запрос → контроллер → бизнес-логика → база → HTTP-ответ. Из всего, что запускается в сборке, это самое близкое к продакшену: настоящий веб-сервер, настоящий порт, настоящая сериализация ответа.

@SpringBootTest(webEnvironment = RANDOM_PORT)
class CreateOrderE2eTest {

    @Autowired
    TestRestTemplate http;

    @Test
    void createsOrderAndReturns201() {
        var body = new CreateOrderRequest(customerId, items);
        var response = http.postForEntity("/orders", body, Void.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

Важная оговорка: всё это по-прежнему одна JVM с тестовым профилем — ни фронта, ни шлюза, ни внешних систем в ней нет. Такой тест честнее называть внутрипроцессным сквозным; путь пользователя целиком проверяют уже на отдельном стенде, где рядом стоят настоящие соседи.

E2e-тесты — самые дорогие: поднимают весь контекст, медленно запускаются, сложно локализовать падение. Их пишут немного — только на ключевые пользовательские сценарии.

Почему наверху мало: мигающие тесты

У пирамиды есть вторая причина сужаться, и она важнее времени прогона. Чем выше уровень, тем чаще тест мигает: то зелёный, то красный при том же самом коде.

Разница между уровнями здесь не в качестве тестов, а в числе участников. Модульный тест ничего не ждёт: у него нет ни сети, ни файлов, ни времени, ни соседей. У интеграционного появляется база, контейнер, порт и общее состояние. У сквозного — ещё и очереди, планировщики, внешние заглушки, браузер. Каждый добавленный участник — это ещё один способ ответить не тогда, когда ждали.

Откуда конкретно берётся мигание:

  • Пауза вместо ожидания. Thread.sleep(500) работает на машине разработчика и не работает на загруженном агенте сборки. Заменяется ожиданием условия с таймаутом (Awaitility): «ждать, пока в базе не появится запись, но не дольше пяти секунд».
  • Общее состояние. Тесты используют одну базу и одни и те же записи: один тест дописывает строку, второй считает и получает не то число. Лечится уникальными данными на каждый тест и очисткой за собой.
  • Порядок выполнения. Тест проходит в одиночку и падает в прогоне, потому что зависит от того, что оставил сосед. Проверяется просто: запустить класс тестов в случайном порядке.
  • Время и часовые пояса. Тест, зелёный днём и красный в полночь, — классика; лечится подменой источника времени, а не выбором «безопасных» дат.
  • Параллельный прогон. Тесты, которые по отдельности честны, вместе конкурируют за порт, за файл, за одну и ту же строку в базе.

Чем это опасно на самом деле: не потерянным временем, а потерей доверия. Один регулярно мигающий тест приучает команду перезапускать сборку не глядя — и через месяц так же перезапускают сборку, упавшую по настоящей причине. Поэтому мигающий тест — это дефект, а не неудобство: его либо чинят, либо помечают и уносят из основного прогона с задачей на исправление, либо удаляют. Чего не делают — не оставляют «иногда падает, просто перезапустите».

Разбор каждой причины с кодом — в статье про интеграционные тесты.

Что тестировать, а что нет

Стоит тестировать:

  • бизнес-правила и граничные случаи (скидки, лимиты, статусные переходы)
  • маппинг данных между слоями
  • обработку ошибочных входных данных
  • взаимодействие с базой на критических путях

Не стоит тестировать:

  • геттеры, сеттеры, конструкторы (очевидный код без логики)
  • конфигурацию фреймворка — Spring уже протестирован сам по себе
  • детали реализации, которые могут поменяться (внутренние приватные методы)

Правило: тест должен сломаться, когда поведение меняется, и оставаться зелёным при рефакторинге без изменения поведения.

Из этого правила выводятся оба края, про которые обычно спорят.

Когда модульный тест бессмысленен. Класс без ветвлений — единственный путь исполнения, проверять нечего, кроме того, что компилятор уже проверил. Тонкая обёртка над репозиторием, вся суть которой в одном вызове: модульный тест на ней проверяет, что мок вызвали, то есть проверяет сам себя, а настоящий вопрос («правильный ли запрос и правильно ли он читает данные») решается только интеграционным тестом. Класс, состоящий из вызовов чужого кода: тест на нём фиксирует вашу фантазию об этом чужом коде. Признак такого теста один: чтобы он упал, нужно изменить сам тест.

Когда сквозной тест оправдан несмотря на цену. Там, где отказ стоит денег или доверия, а путь проходит через несколько систем: оплата от нажатия до подтверждения от провайдера, регистрация с письмом и первым входом, оформление заказа со списанием остатков. Здесь важно не поведение одного класса, а то, что все звенья соединены: конфигурация, права, маршруты, соседи. Ни один нижний уровень этого не проверяет — поэтому один сквозной тест на каждый такой путь пишут и терпят его цену. Ключевое слово «один»: второй сквозной тест на тот же путь с другими данными почти наверняка уже лишний.

Дополнительно: при первом чтении можно пропустить

Глубже: как пишется сам тест: имя, дано-действие-проверка, AssertJрасширенное

Пирамида отвечает, какие тесты и сколько, и не отвечает, как выглядит один тест. А первый рабочий день начинается именно с него.

Имя говорит о поведении, а не о методе: не testCancel, а cancels paid order and refunds payment через @DisplayName или имя метода cancel_paidOrder_refundsPayment. По списку имён упавших тестов должно быть понятно, что сломалось, без чтения кода.

Три части, видимые глазом. Дано: подготовка состояния, объекты, заглушки. Действие: один вызов проверяемого. Проверка: утверждения о результате. Части разделены пустой строкой, и каждая занимает несколько строк, а не экран; подготовка на тридцать строк это сигнал, что нужны билдеры, о которых следующий раздел.

Одно утверждение на смысл. Не одно assert на тест, а одна проверяемая мысль: «заказ отменён и деньги возвращены» это два утверждения об одном поведении в одном тесте, а «отменяется и не отменяется дважды» это два теста. AssertJ читается как фраза и объясняет падение сам:

assertThat(order.status()).as("статус после отмены").isEqualTo(CANCELLED);
assertThat(refunds).extracting(Refund::amount).containsExactly(order.total());
assertThatThrownBy(() -> service.cancel(orderId))
    .isInstanceOf(OrderAlreadyCancelledException.class);

extracting, containsExactly, usingRecursiveComparison для сравнения объектов по полям и as для подписи избавляют от assertTrue(list.size() == 1 && ...), по падению которого не видно ничего. assertAll группирует несколько утверждений, чтобы увидеть все падения сразу, а не первое.

Тест обязан уметь падать. Тест, который зелёный при любом коде, бесполезен и вреден, потому что создаёт уверенность. Проверяют это один раз при написании: сломать проверяемое место (закомментировать возврат денег) и увидеть красный; так же ловят тесты без утверждений и утверждения на заглушках вместо результата. Мутационное тестирование делает это за вас по всему проекту, о чём раздел про покрытие.

Глубже: тестовые данные: билдеры, Object Mother и генераторырасширенное

new Order(...) с многоточием в примерах прячет то, что в реальном проекте занимает больше строк, чем сами проверки: собрать заказ с покупателем, адресом, тремя позициями и оплатой. Конструктор на десять аргументов в каждом тесте делает тесты нечитаемыми и хрупкими: добавили поле в заказ, и сто тестов не компилируются.

Билдер с умолчаниями. Класс, который собирает валидный объект с разумными значениями по умолчанию и позволяет переопределить только то, что важно для теста: anOrder().withStatus(PAID).withItems(item("SKU-1", 2)).build(). Тест показывает лишь значимые поля, остальное скрыто, новое поле в объекте добавляется в билдер один раз. Lombok @Builder с @Builder.Default даёт это почти бесплатно для DTO, для доменных объектов билдер пишут руками, чтобы он проходил через настоящие правила создания.

Object Mother. Именованные готовые сценарии поверх билдеров: Orders.paidOrder(), Orders.orderAwaitingPayment(), Customers.vipCustomer(). Имя говорит, какой случай тестируется, и один и тот же сценарий переиспользуют десятки тестов. Ловушка: общая изменяемая фикстура, которую один тест правит, а другой читает; поэтому каждый вызов возвращает новый объект.

Генераторы. Instancio и подобные заполняют объект случайными валидными значениями по типам и ограничениям: тест, которому нужен «какой-то покупатель», не задаёт ни одного поля, а тест, которому важен email, задаёт только его. Зерно случайности печатается при падении, чтобы воспроизвести. Хорошо для больших DTO и для поиска ошибок на неожиданных значениях, плохо там, где значение важно для смысла: там пишут явно.

Фикстуры под сценарий, а не под проект. Один общий data.sql на все тесты растёт годами, и никто не знает, какой тест от какой строки зависит. Данные готовит сам тест или его класс, через те же билдеры и репозиторий, и они уникальны по ключу, чтобы не мешать соседям при параллельном прогоне.

Глубже: хватает ли тестов: покрытие JaCoCo и мутационное тестированиерасширенное

«Сколько тестов» отвечает пирамида, «хватает ли» не отвечает никто. Два инструмента дают два разных ответа.

Покрытие. JaCoCo записывает, какие строки и ветки выполнились во время тестов, и рисует отчёт: 78 % строк, 61 % ветвлений. Полезно ровно в одном смысле: 0 % у модуля означает, что его не тестируют вовсе, и это стоит знать. Всё остальное покрытие не говорит: строка выполнена не значит проверена, тест без утверждений даёт 100 %, а самая важная ветка «если баланса не хватает» может быть теми 22 %, что не покрыты. Порог в сборке (jacocoTestCoverageVerification с минимумом) защищает от падения покрытия при новом коде и одновременно провоцирует тесты ради процента; ставят его невысоким и по модулям, а не одним числом на проект, и смотрят на непокрытое глазами, а не на процент.

Мутационное тестирование. PIT меняет код по одному месту (условие на противоположное, + на -, убирает вызов) и запускает тесты: если ни один не упал, мутант выжил, и это место не проверено, какое бы покрытие ни было. Доля убитых мутантов честнее процента строк, и отчёт показывает конкретные выжившие мутации, каждая из которых это либо недостающий тест, либо мёртвый код. Цена: прогон в десятки раз дольше тестов, поэтому его гоняют по ядру домена ночью или на изменённых классах в PR, а не по всему проекту на каждый коммит.

Ответ на вопрос «хватает ли». Не число, а три проверки: каждое бизнес-правило названо тестом с понятным именем; мутанты в ядре домена не выживают; последний инцидент воспроизводится тестом, который был бы красным до исправления. Проект, где эти три выполнены при 60 % покрытия, защищён лучше, чем проект с 95 % и тестами на геттеры.

Глубже: тесты в сборке: что на коммит, что на PR, что по расписаниюрасширенное

Пять статей считают секунды, и стоит сказать, где эти секунды тратятся. Тесты раскладывают по ступеням конвейера по цене и по тому, что они ловят, а не гоняют все всегда.

На каждый коммит в ветке: модульные тесты и быстрые проверки (компиляция, линтер), минуты. Их запускает и разработчик локально перед отправкой. На pull request: интеграционные тесты с контейнерами, срезы, контрактные тесты своей стороны; вместе с модульными это бюджет в десять минут, и он защищён: тест, который его ломает, оптимизируют или переносят выше. На слияние в main: то же плюс публикация артефакта и выкат на тестовый контур. По расписанию или на кандидата выпуска: сквозные тесты на препроде, нагрузочные, мутационные, тесты миграций на снимке прода; часы, и падение здесь это не блок слияния, а задача на утро.

Технически это разделение задач сборки (test и integrationTest в Gradle с разными исходными наборами или метками JUnit @Tag) и разные события в файле конвейера, о чём статья про CI для Java. Общее правило: чем дороже тест, тем реже он идёт и тем дальше от коммита; и у каждой ступени есть владелец времени, который видит, что прогон вырос с шести минут до пятнадцати, раньше, чем команда привыкнет ждать.

Коротко

  • Пирамида: много unit → умеренно integration → мало e2e. Чем выше уровень, тем дороже запуск и тем меньше тестов нужно.
  • Unit-тесты — быстрые, изолированные, без Spring-контекста; покрывают бизнес-логику.
  • Integration-тесты — с реальной инфраструктурой (Testcontainers, @SpringBootTest); для репозиториев и HTTP-клиентов. E2e-тесты — полный путь запроса; пишут только на ключевые сценарии.
  • Тестируют поведение и бизнес-правила, не геттеры и не фреймворк.
  • Тест: имя о поведении, три части (дано, действие, проверка), одно утверждение на смысл, AssertJ с подписями; тест обязан уметь падать, и это проверяют при написании.
  • Данные: билдеры с умолчаниями, Object Mother для именованных сценариев, генераторы с зерном для неважных полей, фикстуры под тест, а не общий data.sql.
  • «Хватает ли»: покрытие JaCoCo показывает лишь нули, мутационное тестирование PIT показывает непроверенные места; смотрят на имена правил, выживших мутантов в ядре и тест на последний инцидент.
  • В сборке: модульные на коммит, интеграционные и контрактные на PR в бюджете десяти минут, сквозные, нагрузочные и мутационные по расписанию.
  • Пирамида — модель цены, а не закон: при тонкой логике и толстом SQL форма честнее выглядит кубком (широкий слой интеграционных); неизменно только то, что дорогой тест редок, а один сценарий проверяется на одном уровне.
  • Наверху мало ещё и потому, что чем выше уровень, тем чаще тест мигает; мигающий тест — дефект, а не неудобство, потому что приучает перезапускать сборку не глядя.

Что почитать дальше