Привет, Хабр!
Сегодня рассмотрим 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); } //
}
Феил гарантирован. Правила выживания:
Избегайте мутаций. Пусть поля будут
final
, а коллекции — immutable.Если мутация неизбежна — сбрасывайте её в
@BeforeEach
.Включили параллельный запуск через
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.
Перед миграцией:
Замерьте профит — до и после.
Проверьте мутабельность полей — добавьте очистку в
@BeforeEach
, если нужно.Прогоните параллель — убедитесь, что нет гонок.
Задокументируйте выбор — чтобы решение было прозрачным для всей команды.
В итоге один экземпляр тестового класса способен сэкономить минуты на CI и упростить код, но только при дисциплинированном обращении со стейтом. Действуйте осознанно — и ваши интеграционные тесты будут быстрыми, надёжными и предсказуемыми.
В заключение рекомендую к посещению открытые уроки, которые пройдут в рамках онлайн-курса "Java Developer. Advanced" в OTUS:
Юнит тесты для многопоточного кода — 24 июня в 20:00
LangChain в Java: Langchain4j, Quarkus, Spring Boot — 17 июля в 20:00
Кроме того, пройдите вступительное тестирование, чтобы оценить свой уровень и узнать, подойдет ли вам программа продвинутого курса по Java.