Тестирование Java-кода
Тест фиксирует ожидаемое поведение и позволяет проверить его после изменения кода. Хороший тест объясняет контракт через входные данные, действие и наблюдаемый результат.
Основные уровни
- unit-тест проверяет небольшой участок логики в памяти и изолирует дорогие внешние зависимости;
- интеграционный тест проверяет взаимодействие с БД, брокером, файловой системой или Spring-контекстом;
- компонентный тест проверяет сервис через его публичный интерфейс с контролируемым окружением;
- end-to-end тест проходит пользовательский сценарий через несколько компонентов.
Название уровня менее важно, чем явно определённая граница: что настоящее, что заменено и какую ошибку способен поймать тест.
Где размещать JUnit-тесты
В обычном Maven- или Gradle-проекте production-код и тесты находятся в разных source set:
src/main/java/com/example/PriceCalculator.java
src/test/java/com/example/PriceCalculatorTest.javaclass PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator();
@Test
void addsTaxToBasePrice() {
var result = calculator.withTax(new BigDecimal("100.00"));
assertEquals(new BigDecimal("120.00"), result);
}
}JUnit-зависимость при этом имеет тестовый scope и не попадает в production-код.
Технически тестовый метод можно написать внутри основного класса, если добавить JUnit в основной classpath. Для типичного Java-приложения это плохая граница:
- production-класс начинает зависеть от тестового фреймворка;
- тесты могут попасть в основной артефакт;
- смешиваются ответственность и жизненные циклы кода;
- стандартные сборочные инструменты ожидают тесты в отдельном source set.
Поэтому «inline unit testing» не является обычным способом организации JUnit-тестов в Java.
Как проверять непубличную логику
Private-метод обычно проверяют через публичное поведение класса. Если его трудно проверить, это может означать, что внутри скрыта отдельная ответственность и её стоит вынести в самостоятельный объект.
Для package-private API тест можно поместить в тот же package:
src/main/java/com/example/Parser.java
src/test/java/com/example/ParserTest.javaРефлексия для прямого вызова private-метода связывает тест с реализацией и обычно делает рефакторинг дороже.
Структура теста
Удобная мысленная схема — Arrange, Act, Assert:
@Test
void rejectsNegativeAmount() {
// arrange
var calculator = new PriceCalculator();
// act
var exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.withTax(new BigDecimal("-1"))
);
// assert
assertEquals("amount must be non-negative", exception.getMessage());
}Комментарии можно не писать, если три части и так хорошо видны.
Что делает тест полезным
- проверяет наблюдаемое поведение, а не последовательность внутренних вызовов без необходимости;
- имеет понятное имя и одну причину падения;
- детерминирован: не зависит от текущего времени, порядка запуска и чужих данных;
- не разделяет изменяемое состояние с соседними тестами;
- содержит граничные и ошибочные сценарии, а не только happy path;
- падает по понятной причине при нарушении контракта.
Частые ошибки
- мокировать каждый объект и в итоге тестировать только настройку моков;
- поднимать полный Spring-контекст для простой функции;
- использовать случайные данные без сохранённого seed;
- проверять только факт отсутствия исключения;
- делать тест зависимым от уже запущенной локальной БД;
- лечить нестабильный тест повторным запуском вместо поиска гонки или общей зависимости.
На собеседовании
Нужно ли тестировать private-методы напрямую?
Обычно нет. Тест проверяет контракт через публичный или package-private интерфейс. Если сложный private-метод требует отдельного набора тестов, часто это сигнал выделить его ответственность в отдельный класс.