Тестирование 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.java
class 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-метод требует отдельного набора тестов, часто это сигнал выделить его ответственность в отдельный класс.

Связанные заметки