Привет, Хабр!

Сегодня рассмотрим JUnit 5 и разберёмся, чем дышит аннотация @TestInstance(PER_CLASS), — зачем переопределять жизненный цикл тестового инстанса и когда это может помочь.

PER_CLASS — это когда фреймворк создаёт один объект вашего теста на весь класс, а не по объекту на каждый метод. Взамен вы:

  • Можете писать @BeforeAll/@AfterAll без static.

  • Держите дорогие ресурсы (Docker‑контейнер, embedded‑Kafka, что угодно) в поле и не пересоздаёте их по тысяче раз.

  • Рискуете нахвататься shared‑state‑хейтерства, если забыли причесать поля перед следующим тестом.

Рассмотрим подробнее.

Что JUnit думает о жизненном цикле

По дефолту Jupiter создаёт новый экземпляр тестового класса каждый раз перед вызовом метода — модель PER_METHOD. Это историческое наследие борьбы с мутабельностью: нет объекта — нет стейта, нет проблем. Но аннотация

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MyExpensiveIntegrationTest { ... }

меняет правила: объект один, методы бегают поочерёдно внутри него. JUnit официально благословил режим с пояснением, что он полезен, но требует дисциплины — сбросьте состояние сами, если оно вам дорого.

@BeforeAll без static

В PER_CLASS можно писать так:

@BeforeAll
void bootKafka() {
   kafka = Testcontainers.startKafka();
}

Никаких static void, приятно. Под руководство попадают и @Nested классы — там тоже оживает @BeforeAll без статики, что спасает от странных костылей в Java ≤ 15.

Когда это ускоряет сборку

Допустим, в проекте около сотни тестов поднимают embedded ElasticSearch. Каждая инициализация ≈ 800 мс. На CI это превращается в +1 мин к билду. Можно перевести класс‑хозяин на PER_CLASS — и валидацию индексов оставить в @AfterAll.

Пример переезда:

@TestInstance(PER_CLASS)
class ElasticSearchIT {

   private ElasticContainer container;

   @BeforeAll
   void startEs() {
      container = new ElasticContainer("docker.elastic.co/elasticsearch/elasticsearch:8.13.0");
      container.start();
   }

   @Test
   void shouldIndexDocument() {
      // ...
   }

   @AfterAll
   void stopEs() {
      container.stop();
   }
}

Режим

Время, с

PER_METHOD

78

PER_CLASS

19

Профит очевиден, но…

Темная сторона shared state

С одним инстансом легко случайно пропылесосить переменной между тестами:

@TestInstance(PER_CLASS)
class CounterTest {
   private int counter = 0;

   @Test void increments()   { counter++; assertEquals(1, counter); }
   @Test void stillZero()    { assertEquals(0, counter); } // 
}

Феил гарантирован. Правила выживания:

  1. Избегайте мутаций. Пусть поля будут final, а коллекции — immutable.

  2. Если мутация неизбежна — сбрасывайте её в @BeforeEach.

  3. Включили параллельный запуск через junit.jupiter.execution.parallel.enabled=true? Обеспечьте синхронизацию или откажитесь от PER_CLASS вовсе.

Конфигурация на уровне проекта

Надоело размазывать аннотацию по классам? Положите в src/test/resources файл junit-platform.properties:

junit.jupiter.testinstance.lifecycle.default = per_class

JUnit подхватит настройку глобально. Но будьте осторожны: IDE может запускать тесты со своим рантаймом и проигнорировать property.

Жизненный цикл с DI и Extentions

Spring Boot + JUnit 5

В спринговых тестах @SpringBootTest уже создаёт контекст один раз на класс, так что PER_CLASS как бы логично. Однако Spring тест‑раннер сам решает, когда рестартовать контекст между классами, и дополнительный shared state может смешать карты кэшам и MockBean»ам. Простой критерий: если вы не мутируете бины — смело включайте.

Mockito Extension

@ExtendWith(MockitoExtension.class)
@TestInstance(PER_CLASS)
class UserServiceTest { ... }

MockitoExtension хранит мок‑прокси внутри каждого тестового экземпляра, так что в PER_CLASS вы получаете одни и те же моки на все методы. Good! Но не забудьте в @BeforeEach делать reset(userRepository) — иначе порядок вызовов смешается.

TestInstanceFactory

С JUnit 5.9 появилась возможность самому создавать экземпляры тестов через TestInstanceFactory. В связке с PER_CLASS можно заинжектить депсы прямиком из Spring‑контейнера или Dagger‑модуля:

public class SpringAwareFactory implements TestInstanceFactory {
   @Override
   public Object createTestInstance(TestInstanceFactoryContext ctx, ExtensionContext ex) {
      ApplicationContext appCtx = SpringExtension.getApplicationContext(ex);
      return appCtx.getBean(ctx.getTestClass());
   }
}

Регистрируем через @ExtendWith, экономим конструкторный DI и сохраним state между методами. Но регистрировать две фабрики на класс — ошибка.


Заключение

@TestInstance(PER_CLASS) — это инструмент ускорения тестов и оптимизации ресурсов, а не крутой трюк. Используйте его, когда:

  • инициализация окружения дорога по времени или деньгам;

  • нужен не‑static @BeforeAll/@AfterAll;

  • управление общими ресурсами явно прописано и проверено.

Не включайте, если:

  • тесты запускаются параллельно и состояние нельзя надёжно обнулить;

  • важна независимость между методами (property‑based или fuzz‑тесты);

  • в команде нет чётких договорённостей о работе с shared state.

Перед миграцией:

  1. Замерьте профит — до и после.

  2. Проверьте мутабельность полей — добавьте очистку в @BeforeEach, если нужно.

  3. Прогоните параллель — убедитесь, что нет гонок.

  4. Задокументируйте выбор — чтобы решение было прозрачным для всей команды.

В итоге один экземпляр тестового класса способен сэкономить минуты на CI и упростить код, но только при дисциплинированном обращении со стейтом. Действуйте осознанно — и ваши интеграционные тесты будут быстрыми, надёжными и предсказуемыми.


В заключение рекомендую к посещению открытые уроки, которые пройдут в рамках онлайн-курса "Java Developer. Advanced" в OTUS:

  1. Юнит тесты для многопоточного кода — 24 июня в 20:00

  2. LangChain в Java: Langchain4j, Quarkus, Spring Boot — 17 июля в 20:00

Кроме того, пройдите вступительное тестирование, чтобы оценить свой уровень и узнать, подойдет ли вам программа продвинутого курса по Java.

Комментарии (0)