Введение: «Почему мой тест падает, когда должен проходить?»
Вы когда-нибудь писали юнит-тест, который должен был ловить исключение, а он вместо этого падал с красной ошибкой? Знакомо? Вопрос «How do you assert that a certain exception is thrown in JUnit tests?» на Stack Overflow собрал тысячи просмотров и сотни комментариев. И это не случайно: тестирование исключений — одна из тех тем, где даже опытные разработчики иногда спотыкаются.
В 2025 году, когда качество кода стало критическим фактором (особенно в микросервисах и AI-пайплайнах), умение правильно проверять исключения — это не просто «хороший тон», а базовая компетенция. В этой статье мы разберём все актуальные способы assert исключений в JUnit 5, покажем подводные камни и дадим чек-лист лучших практик. Поехали!
Почему важно тестировать исключения?
Представьте: вы пишете метод divide(int a, int b). Если b == 0, метод должен кидать ArithmeticException. Без теста на это поведение вы рискуете, что через месяц другой разработчик (или вы сами) случайно уберёт проверку, и приложение упадёт в production с непонятной ошибкой.
Тесты на исключения — это ваша страховка. Они документируют ожидаемое поведение системы в граничных случаях. И JUnit 5 даёт для этого несколько элегантных инструментов.
Способ 1: assertThrows — король современного тестирования
Начиная с JUnit 5 (и его пакета org.junit.jupiter.api), метод assertThrows стал стандартом де-факто. Почему? Потому что он лаконичный, читаемый и функциональный.
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void whenDivideByZero_thenArithmeticException() {
assertThrows(ArithmeticException.class,
() -> calculator.divide(10, 0));
}
Что здесь происходит? assertThrows принимает класс ожидаемого исключения и лямбду с кодом, который должен его выбросить. Если исключение выбрасывается — тест зелёный. Если нет — красный. Всё просто и наглядно.
Расширенный вариант: проверяем сообщение исключения
Иногда недостаточно просто проверить тип исключения — нужно убедиться, что сообщение содержит правильный текст. assertThrows возвращает объект исключения, и вы можете проверить его свойства:
import static org.junit.jupiter.api.Assertions.*;
@Test
void whenInvalidEmail_thenExceptionWithMessage() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.register("invalid-email")
);
assertTrue(exception.getMessage().contains("Некорректный email"));
}
Этот подход особенно полезен при тестировании кастомных исключений с полями (например, кодом ошибки).
Способ 2: Старый добрый expected (JUnit 4 legacy)
Если вы работаете с JUnit 4 или поддерживаете легаси-код, то наверняка встречали атрибут expected в аннотации @Test:
@Test(expected = ArithmeticException.class)
public void whenDivideByZero_thenException() {
calculator.divide(10, 0);
}
Минус: этот способ не позволяет проверить сообщение исключения или его свойства. Кроме того, если исключение выбросится раньше, чем вы ожидаете (например, в строчке инициализации), тест может пройти ложно. В 2025 году рекомендуем избегать этого подхода в новом коде.
Способ 3: Try-catch с флагом (для фанатов контроля)
До появления JUnit 5 многие использовали конструкцию с try-catch и булевым флагом. Выглядит громоздко, но даёт полный контроль:
@Test
void whenDivideByZero_thenExceptionCaught() {
boolean thrown = false;
try {
calculator.divide(10, 0);
} catch (ArithmeticException e) {
thrown = true;
assertEquals("Division by zero", e.getMessage());
}
assertTrue(thrown, "ArithmeticException was not thrown");
}
Сегодня этот способ считается устаревшим. Он многословен, легко допустить ошибку (например, забыть установить флаг). Используйте его только если у вас нет выбора (например, в очень старых версиях JUnit).
Способ 4: Правило ExpectedException (JUnit 4 Rule)
Ещё один пережиток прошлого — @Rule ExpectedException. Он позволял декларативно указать ожидаемое исключение и проверять его свойства:
import org.junit.Rule;
import org.junit.rules.ExpectedException;
public class CalculatorTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void whenDivideByZero_thenException() {
thrown.expect(ArithmeticException.class);
thrown.expectMessage("Division by zero");
calculator.divide(10, 0);
}
}
В JUnit 5 этот механизм удалён. Если вы мигрируете с JUnit 4, просто замените его на assertThrows — это будет чище и надёжнее.
Лучшие практики 2025: чек-лист для профессионала
Мы собрали рекомендации, которые помогут вам писать тесты на исключения, от которых не будет болеть голова у коллег:
- Всегда используйте assertThrows из JUnit 5. Это современно, читаемо и функционально.
- Проверяйте сообщение исключения, если оно несёт бизнес-смысл. Не проверяйте точное совпадение — используйте
contains()илиmatches(), чтобы тест не сломался при смене формулировки. - Тестируйте только одно исключение на тест. Если метод может кидать разные исключения при разных условиях — пишите отдельные тесты. Это улучшает читаемость и упрощает отладку.
- Не тестируйте unchecked исключения без необходимости.
NullPointerExceptionилиArrayIndexOutOfBoundsExceptionобычно говорят о баге в коде, а не о контракте метода. Лучше исправить баг, чем тестировать его. - Документируйте причину исключения в assertThrows. Добавляйте сообщение в третий параметр:
assertThrows(..., () -> ..., "Должно быть исключение при делении на 0"). Это помогает быстро понять, что пошло не так, если тест упадёт. - Используйте вложенные assertThrows для сложной логики. Если ваш метод делает несколько операций, и вы хотите проверить, что исключение выбрасывается на конкретном шаге — оберните именно этот шаг в лямбду.
Реальный пример: тестирование сервиса с валидацией
Давайте соберём всё вместе. Предположим, у нас есть сервис регистрации пользователей, который кидает RegistrationException с кодом ошиб