Класс тестов JUnit 5 проходит локально, но в CI падает через раз — разберите причину
Этот класс тестов зелёный на каждой машине разработчика и зелёный, когда любой из тестов запускают в одиночку, но примерно каждая четвёртая сборка CI падает на expiresOldOrder с expected: <true> but was: <false> — а перезапуск той же сборки обычно её чинит.
Ограничения:
- боевой код корректен и менять его нельзя
- «просто перезапустить» — не ответ; назовите, что делает результат непостоянным
class OrderServiceTest {
private static final List<Order> ORDERS = new ArrayList<>();
@Test
void createsOrder() {
ORDERS.add(new Order("A-1", LocalDate.now()));
assertEquals(1, ORDERS.size());
}
@Test
void expiresOldOrder() {
ORDERS.add(new Order("A-2", LocalDate.now().minusDays(30)));
Order first = ORDERS.get(0);
assertTrue(first.isExpired()); // ← падает здесь, иногда
}
}
Определите причину.
Тесты делят изменяемое состояние через static-список и зависят от порядка выполнения, а JUnit 5 его не гарантирует. Если первым отработал createsOrder, то ORDERS.get(0) — сегодняшний заказ, он не просрочен, и проверка падает. В одиночку или в другом порядке тест проходит. Лечится переводом фикстуры на каждый тест (нестатическое поле, сбрасываемое в @BeforeEach) и проверкой заказа, созданного самим тестом, а не элемента с индексом 0.
- ✗Считать, что JUnit выполняет тестовые методы в порядке объявления
- ✗Называть исправлением правило перезапуска — оно прячет общее состояние, а не убирает его
- ✗Делить фикстуру через
static-поле, чтобы «сэкономить» на подготовке
- →Почему чтение системных часов — такой частый источник нестабильности и как его убрать?
- →Почему
@TestMethodOrderзаставит набор проходить, но не сделает его корректным?
Что делает результат непостоянным
Два независимых дефекта, и оба сводятся к одному: тест зависит от чего-то, чем не управляет.
- Общее изменяемое состояние.
ORDERSобъявленstatic, поэтому список один на весь класс и живёт между тестами. JUnit 5 создаёт новый экземпляр тестового класса на каждый метод — но статическое поле это не сбрасывает. - Зависимость от порядка. JUnit не гарантирует порядок методов: по умолчанию он детерминированно-но-непрозрачный (
MethodOrdererне задан — порядок зависит от рефлексии и может отличаться между JDK/сборками). Отсюда и «каждая четвёртая сборка».
Если первым отработал createsOrder, к моменту expiresOldOrder в списке уже лежит сегодняшний заказ, и ORDERS.get(0) возвращает его, а не тот, что создал сам тест:
OrderServiceTest > expiresOldOrder() FAILED
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
at OrderServiceTest.expiresOldOrder(OrderServiceTest.java:18)
ORDERS.get(0) — заказ A-1 с датой LocalDate.now(). Он, разумеется, не просрочен.
Исправление
class OrderServiceTest {
private List<Order> orders; // ✅ поле экземпляра, не static
@BeforeEach
void setUp() {
orders = new ArrayList<>(); // ✅ фикстура заново на каждый тест
}
@Test
void expiresOldOrder() {
Order old = new Order("A-2", LocalDate.now().minusDays(30));
orders.add(old);
assertTrue(old.isExpired()); // ✅ проверяем СВОЙ заказ, а не get(0)
}
}
Теперь каждый тест видит только то, что создал сам: порядок перестаёт что-либо значить, а падение — воспроизводиться.
⚠️ @TestMethodOrder(OrderAnnotation.class) «починит» набор — и это ловушка. Он лишь закрепит один порядок, оставив тесты связанными общим состоянием: первый же новый тест, вставленный в середину, вернёт всё обратно. Правило перезапуска (retry) прячет проблему ровно так же.
⚠️ Вторая, менее заметная зависимость — системные часы: LocalDate.now() делает тест зависимым от даты прогона. Для боевого кода, где важна граница («просрочен через 30 дней»), внедряйте Clock и подставляйте Clock.fixed(...) — тогда результат не зависит ни от дня, ни от таймзоны агента.