Для тестирования Java-приложений создано столько полезных инструментов, что в них немудрено запутаться. Вместе с тем с помощью каждого из них можно решить строго определенный круг прикладных задач. Меня зовут Константин Шибков, я старший Java-разработчик CDEK и программный директор курсов по Java в Skillbox. Сегодня расскажу о практике использования фреймворка для модульного тестирования JUnit 5, а также покажу на конкретных примерах, как проводятся стандартные юнит-тесты и параметризованные тесты. Вы сможете самостоятельно повторить их, воспользовавшись подготовленным репозиторием с готовыми примерами тестов на Java. 

Кратко о тестировании Java-приложений

Ни один инструмент не может существовать сам по себе, «в вакууме». Например, тот же Mockito применяется для создания заглушек, моков, то есть это дополнительный инструмент, используемый при работе в других фреймворках. При тестировании любого приложения работа проводится на нескольких уровнях, и в этом тестировщику помогает разный софт. Наглядно проиллюстрировать это утверждение можно с помощью так называемой пирамиды тестирования.

Основа — это юнит-тесты, с помощью которых тестируется каждый отдельный класс или каждый метод этого класса. Это самые маленькие, быстрые и эффективные тесты, ими необходимо максимально покрыть приложение. Юнит-тестированием занимаются в основном разработчики. К более высокому уровню относятся функциональные интеграционные тесты, захватывающие компоненты и классы внутри приложения, их глобальное взаимодействие и взаимосвязи. Иными словами, в процессе функционального тестирования мы запускаем приложение и начинаем взаимодействовать с ним, отслеживая корректность работы всех слоёв и компонентов программы. Эту задачу можно решить с помощью нескольких инструментов. Один из них — это JUnit 5, о котором сегодня и пойдет речь.

JUnit — это среда модульного тестирования для языка программирования Java. Фреймворк применяется не только для поиска дефектов в приложениях, но и в целях разработки через тестирование. Давайте рассмотрим его возможности и методы использования на нескольких практических примерах.

Для облегчения этой задачи я создал отдельный репозиторий, содержащий специальный проект с юнит- и интеграционными тестами: https://github.com/sendelufa/spring_boot_tests. Вы можете использовать код из этого проекта, чтобы самостоятельно провести описанные в этой статье тесты и поэкспериментировать с JUnit 5.

Стандартный юнит-тест

Подключение к проекту выполняется довольно просто. Если вы используете spring-boot-starter-test, то JUnit 5 уже включен в проект начиная с версии 2.2.0. При использовании фреймворка для сборки проектов Apache Maven используется описание следующего вида:

    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>

А в системе автоматической сборки Gradle используется следующий сценарий:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
}

Для того чтобы JUnit увидел тест, достаточно указать аннотацию @Test. Простой юнит-тест будет выглядеть так:

class DurationToLengthConverterTest {

  @Test
  void lengthZero() {
    // Setup
    var song = SongPersist.builder()
           .duration(Duration.ZERO)
           .build();

    // Execution
    var converter = new DurationToLengthConverter(song);

    // Assertion
    Assertions.assertEquals("0:00", converter.length());
  }
}

Это стандартный юнит-тест, покрывающий все три этапа тестирования приложения: Настройка (Setup), Выполнение (Execution) и Проверка (Assertion).

​​По умолчанию JUnit использует в своих отчетах о выполнении имена методов или классов в качестве имени теста. Однако такие имена могут быть недостаточно информативными или слишком техническими, поэтому при изучении отчета можно запутаться в назначении того или иного теста. Для решения этой проблемы следует использовать аннотацию @DisplayName. Она позволяет задать более понятные и информативные имена, которые легче понять при просмотре отчетов или логов. Аннотация может быть установлена для метода или класса.

@DisplayName("DurationToLengthConverter Test")
public class DurationToLengthConverterTest {

    @Test
    @DisplayName("Zero Length Test")
    void lengthZero() {
        // Setup
        var song = SongPersist.builder()
                .duration(Duration.ZERO)
                .build();

        // Execution
        var converter = new DurationToLengthConverter(song);

        // Assertion
        Assertions.assertEquals("0:00", converter.length());
    }
}

Со стандартным юнит-тестом мы разобрались. Однако сейчас мы проверили только один кейс, в котором приложению передается значение 0. А что делать, если необходимо проверить различные случаи или несколько самостоятельных утверждений?

Можно написать ряд отдельных тест-методов, в которых будет проверяться каждый конкретный случай, но это довольно большой объем повторяющегося кода. Гораздо удобнее и практичнее выделить тестируемый объект в отдельный метод, а другие тест-методы будут передавать в него значения:

@DisplayName("Zero Seconds Length Test")
void zeroSecondsSongDuration(){
   durationConverterWithParamsTest(Duration.ofSeconds(0), "0:00");
}

private void durationConverterWithParamsTest(Duration songDuration, String expectedDurationString){
   var song = SongPersist.builder()
           .duration(songDuration)
           .build();

   var converter = new DurationToLengthConverter(song);

   Assertions.assertEquals(expectedDurationString, converter.length());

Таким образом, каждый тест будет независимым: даже если один упадет, то другие все равно запустятся и принесут результат. А вот объединять несколько кейсов в один не стоит, так как при первом провале проверки assert следующие не запустятся и результат их проверки будет известен только после исправления работы упавшего утверждения.

Как не стоит делать:

@Test
@DisplayName("Ten Seconds Length Test")
void tenSecondsSongDuration(){
   durationConverterWithParamsTest(Duration.ofSeconds(10), "0:10");
   durationConverterWithParamsTest(Duration.ofSeconds(0), "0:00");

}

Параметризованные тесты

Мы рассмотрели ситуацию, когда на каждый случай нужно написать метод. А если таких случаев намного больше двух?

С помощью JUnit 5 можно писать и параметризованные тесты — для этого существует специальная библиотека, работающая в связке с фреймворком. Если вы используете spring-boot-starter-test, то ничего дополнительно подключать не нужно, в других случаях следует добавить зависимости. В Apache Maven это делается следующим образом:

<dependency>
  <groupId>org.junit.jupiter</groupId> 
  <artifactId>junit-jupiter-params</artifactId>
  <version>5.10.0</version>
  <scope>test</scope>
</dependency>

В Gradle это делается так:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.4.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
}

Теперь написать несколько тест-кейсов для нас не составит труда. Для этого нужно использовать одну обязательную аннотацию @ParameterizedTest, которая заменяет @Test над тестовым методом, а затем определить аргументы тестового метода и источник данных для аргументов.

Мы будем использовать значение Duration и строковое значение результата. Для двух аргументов лучше всего передавать параметры через @CsvSource. Аннотация принимает список строк, в которых через запятую перечисляются аргументы метода: 

@ParameterizedTest
@CsvSource({ "0,0:00", "10,0:10", "60,1:00", "95,1:35" })
@DisplayName("Duration to Length")
void variousSongDurationTests(int durationSeconds, String songLength) {
   var song = SongPersist.builder()
           .duration(Duration.ofSeconds(durationSeconds))
           .build();

   var converter = new DurationToLengthConverter(song);

   Assertions.assertEquals(songLength, converter.length());

При запуске в среде разработки такие тесты группируются с указанием аргументов.

Строковые значения автоматически преобразуются в числа (включая BigDecimal/BigInteger), дата и время берутся из пакета java.time. Другие значения тоже поддерживаются: boolean, enum, Path, URI, URL, UUID, Locale, Currency, Class. С учетом этого можно сразу принимать Duration в качестве аргумента следующим образом:

@ParameterizedTest
@CsvSource({ "PT0S,0:00", "PT10S,0:10", "PT1M,1:00", "PT1M35S,1:35" })
@DisplayName("Duration to Length")
void variousSongDurationTests(Duration duration, String songLength) {
   var song = SongPersist.builder()
           .duration(duration)
           .build();

   var converter = new DurationToLengthConverter(song);

   Assertions.assertEquals(songLength, converter.length());
}

Кроме того, что первый аргумент изменил тип на Duration, нам пришлось заменить и формат строки для него. Мы вынуждены это сделать, так как по умолчанию для создания объекта Duration используется метод parse, который принимает формат строки в виде указания длительности времени согласно стандарту ISO-8601.

Каждый из форматов строки для других классов вы найдете в документации к этому классу.

Если нам нужно использовать в аргументах объекты, которых нет в стандартном наборе junit-jupiter-params, то мы можем написать другую конструкцию:

public static Stream<Arguments> songAndLength() {
   return Stream.of(
           Arguments.of(SongPersist.builder()
                           .duration(Duration.ofSeconds(0))
                           .build(), "0:00"),
           Arguments.of(SongPersist.builder()
                           .duration(Duration.ofSeconds(178))
                           .build(), "2:58"),
           Arguments.of(SongPersist.builder()
                           .duration(Duration.ofSeconds(10))
                           .build(), "0:10")
   );
}

@ParameterizedTest
@MethodSource("songAndLength")
@DisplayName("Duration to Length")
void variousSongDurationTests(SongPersist songPersist, String songLength) {
   var converter = new DurationToLengthConverter(songPersist);

   Assertions.assertEquals(songLength, converter.length());
}

Порядок классов в каждом наборе аргументов Arguments.of(...) должен соответствовать типу и порядку в аргументах тестируемого класса.

Таким образом мы можем сразу создать параметр songPersist и использовать его в последующих тестах. Да, в нашем случае вариант с @CsvSource выглядит более читаемым и понятным, и необходимости в @MethodSource нет, но при создании сложных структур @MethodSource крайне полезен, и забывать о нем не следует.

Вот еще один пример @MethodSource для передачи одного аргумента. Давайте проверим валидацию строки с длительностью мелодии при создании SongAddRequest:

class SongAddRequestTest {

   private Validator validator;

   @BeforeEach
   public void setup() {
       validator = Validation.buildDefaultValidatorFactory().getValidator();
   }

   public static Stream<String> validTimeLength() {
       return Stream.of("0:00", "10:10", "60:00", "0:70", "100:00");
   }

   @ParameterizedTest
   @MethodSource("validTimeLength")
   public void testValidTimeLengthSongAddRequest(String timeLength) {
       // Создаем экземпляр SongAddRequest с валидными значениями
       var songAddRequest = new SongAddRequest("Title", "Author", timeLength, "Genre");

       // Проверяем, что валидация проходит успешно
       Set<ConstraintViolation<SongAddRequest>> violations = validator.validate(songAddRequest);
       assertTrue(violations.isEmpty());
   }
}

Таким образом можно передавать и другие классы, не только String. А String можно было передавать и через @CsvSource — код получился бы компактнее.

Вы можете попробовать самостоятельно дописать тесты для проверки того, что валидация не прошла. Например, если передано значение “l0:00”.

Улучшаем «читабельность» тестов

Если вы используете spring-boot-starter-test, вам снова ничего не надо подключать — этот инструмент уже присутствует «из коробки». В другом случае добавьте зависимость. В Maven:

<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <version>3.24.2</version>
  <scope>test</scope>
</dependency>

В Gradle:

dependencies {
    …
    testImplementation("org.assertj:assertj-core:3.24.2")
    …
}

Наши тесты уже стали лучше читаться, стало проще добавлять различные кейсы. Но результаты тестов не всегда выглядят наглядно. Давайте посмотрим, как будет выглядеть результат проверки из теста testValidTimeLengthSongAddRequest(). Представим, что мы решили расширить длительность композиции до максимального значения 10_000 минут, но сейчас у нас максимально допустимо 1_000 минут. Добавим такой тесткейс.

public static Stream<String> validTimeLength() {
   return Stream.of("0:00", "10:10", "60:00", "0:70",
               "100:00", "1000:00", "10000:00");
}

Последний тесткейс не завершится успехом, и мы увидим такой результат:

Не совсем понятно, что означает true, а что false. Один из вариантов — использовать перегруженный метод с дополнительным сообщением о причинах ошибки:

assertTrue(violations.isEmpty(), 
    String.format("timeLength=%s is not valid, %s", timeLength, violations));

Теперь у нас достаточно данных о причинах провала теста:

В этом случае наглядно видно, что переданное значение не прошло проверку регулярным выражением. Чтобы тест завершился успешно, достаточно изменить регулярное выражение.

Однако на практике такой подход используется редко, поскольку для этого надо придумать сообщение, передать в него необходимые данные.

И тут нам приходит на помощь библиотека AsserJ, которая содержит широкий спектр различных assert-выражений. Например, при использовании AssertJ мы можем переписать проверку таким образом:

@ParameterizedTest
@MethodSource("validTimeLength")
public void testValidSongAddRequestAssertJ(String timeLength) {
   var songAddRequest = 
         new SongAddRequest("Title", "Author", timeLength, "Genre");

   var violations = validator.validate(songAddRequest);

   assertThat(violations).isEmpty();
}

Общая структура для большинства тестов использует asserThat, в который передается проверяемое значение, и далее используются проверки в зависимости от переданного типа. Коллекции можно проверить на пустоту через isEmpty(), или, наоборот, на существование в коллекции не менее одного элемента при помощи isNotEmpty(). В нашем случае подходит первый вариант. 

Посмотрим на результат такой проверки:

Мы получили подробное и понятное описание провала проверки, при этом нам не потребовалось сочинять собственное сообщение.

У AsserJ богатый API для проверок различных типов. Еще один частый пример — это сравнение BigDecimal. Корректно проверить BigDecimal можно только с помощью метода compareTo, простой equals не учитывает точность числа и другие внутренние параметры.

@Test
void pureJunitTest() {
   var actual = new BigDecimal("1234.00100");
   var expected = new BigDecimal("1234.001");

   // нам скажут, что числа разные
   assertEquals(expected, actual);
}

Правильная проверка посредством compareTo при использовании только JUnit5 с выделенным сообщением выглядит следующим образом:

@Test
void pureJunitTest() {
   var actual = new BigDecimal("1234.00100");
   var expected = new BigDecimal("1234.001");

   assertTrue(String.format("actual=%s, expected=%s", actual, expected),
           actual.compareTo(expected) == 0);
}

А если использовать AssertJ, то снова можно упростить себе жизнь:

@Test
void assertJTest() {
   var actual = new BigDecimal("1234.00100");
   var expected = new BigDecimal("1234.001");

   assertThat(actual).isEqualByComparingTo(expected);
}

Множество отличных примеров вы можете найти в официальной документации на странице https://assertj.github.io/doc/#assertj-core-assertions-guide. С использованием  AsserJ ваши тесты станут лучше читаться, а результат провала теста даст ценную и понятную информацию для планирования дальнейших действий.

Вместо заключения

Мы рассмотрели только несколько довольно простых примеров использования JUnit 5, в то время как функциональные возможности фреймворка, разумеется, намного шире. Однако уже эти примеры наглядно демонстрируют простоту и удобство использования данного инструмента. Вы можете самостоятельно загрузить проект из репозитория и провести тесты с помощью JUnit 5, поэкспериментировав с конфигурацией и передаваемыми параметрами. Это позволит вам убедиться в том, что JUnit 5 недаром пользуется заслуженной популярностью у тестировщиков Java-приложений. Он позволяет закрыть большинство стандартных кейсов при написании юнит-тестов и проведении регрессионного тестирования.

Комментарии (3)


  1. Folko85
    16.10.2023 15:05

    Базовые Unit-тесты неплохо описаны, но эту тему конечно надо развивать. Хотелось бы увидеть статьи про интеграционные и e2e-тесты, различные фреймворки и т.д..


  1. CyberPaul
    16.10.2023 15:05

    Чего-то такого простого и понятного по юнит тестированию давно не хватало. Спасибо за статью.


  1. amedvedjev
    16.10.2023 15:05

    Таким образом, каждый тест будет независимым: даже если один упадет, то другие все равно запустятся и принесут результат. А вот объединять несколько кейсов в один не стоит, так как при первом провале проверки assert следующие не запустятся и результат их проверки будет известен только после исправления работы упавшего утверждения.

    Тут все зависит:

    • Какая сложность и временные затраты на подготовку теста (Setup)

    • Возможно ли проверить что то еще без продолжения теста. Например проверяется сразу несколько параметров в ответе.

    • Читаемость. Будет ли сохранена читаемость теста при обьединении.

    • Очень часто тест состоит из тескольких частей. И часто мелкие ошибки не фатальны для продолжения теста. В таких случаях очень удобно проверять мягкими ассертами (Soft Assert поддерживаются с jUnit5 и в testNG). Которые не останавливают тест, но фейлят в самом конце. Таким образом можно ловить сразу несколько ошибок.