«1. Обзор

JUnit 5, следующее поколение JUnit, упрощает написание тестов для разработчиков благодаря новым блестящим функциям.

Одной из таких функций являются параметризованные тесты. Эта функция позволяет нам выполнять один и тот же метод тестирования несколько раз с разными параметрами.

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

2. Зависимости

Чтобы использовать параметризованные тесты JUnit 5, нам нужно импортировать артефакт junit-jupiter-params из платформы JUnit. Это означает, что при использовании Maven мы добавим в наш pom.xml следующее:

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

Кроме того, при использовании Gradle мы укажем его немного по-другому:

testCompile("org.junit.jupiter:junit-jupiter-params:5.8.1")

3. Первое впечатление

Допустим, у нас есть существующая служебная функция, и мы хотели бы быть уверены в ее поведении:

public class Numbers {
    public static boolean isOdd(int number) {
        return number % 2 != 0;
    }
}

Параметризованные тесты похожи на другие тесты, за исключением того, что мы добавляем аннотацию @ParameterizedTest:

@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

~~ ~ Средство запуска тестов JUnit 5 выполняет этот вышеуказанный тест — и, следовательно, метод isOdd — шесть раз. И каждый раз он присваивает другое значение из массива @ValueSource параметру числового метода.

Итак, этот пример показывает нам две вещи, которые нам нужны для параметризованного теста:

    источник аргументов, в данном случае массив int способ доступа к ним, в данном случае числовой параметр

Там — еще один аспект, не очевидный в этом примере, поэтому мы продолжим поиски.

4. Источники аргументов

Как мы уже должны знать, параметризованный тест выполняет один и тот же тест несколько раз с разными аргументами.

И, надеюсь, мы можем делать больше, чем просто числа, так что давайте исследовать.

4.1. Простые значения

С помощью аннотации @ValueSource мы можем передать массив литеральных значений в тестовый метод.

Предположим, мы собираемся протестировать наш простой метод isBlank:

public class Strings {
    public static boolean isBlank(String input) {
        return input == null || input.trim().isEmpty();
    }
}

Мы ожидаем, что этот метод вернет true для null для пустых строк. Итак, мы можем написать параметризованный тест, подтверждающий это поведение:

@ParameterizedTest
@ValueSource(strings = {"", "  "})
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

Как мы видим, JUnit будет запускать этот тест два раза и каждый раз присваивать один аргумент из массива параметру метода.

Одним из ограничений источников значений является то, что они поддерживают только следующие типы:

    short (с атрибутом shorts) byte (атрибут bytes) int (атрибут int) long (атрибут longs) float (атрибут float) double ( атрибут doubles) char (атрибут chars) java.lang.String (атрибут strings) java.lang.Class (атрибут classs)

Кроме того, каждый раз мы можем передавать только один аргумент в тестовый метод.

Прежде чем идти дальше, обратите внимание, что мы не передавали null в качестве аргумента. Это еще одно ограничение — мы не можем передать null через @ValueSource, даже для String и Class.

4.2. Нулевые и пустые значения

Начиная с JUnit 5.4, мы можем передать одно нулевое значение параметризованному тестовому методу, используя @NullSource:

@ParameterizedTest
@NullSource
void isBlank_ShouldReturnTrueForNullInputs(String input) {
    assertTrue(Strings.isBlank(input));
}

Поскольку примитивные типы данных не могут принимать нулевые значения, мы не можем использовать @NullSource для примитивных аргументов.

Точно так же мы можем передавать пустые значения, используя аннотацию @EmptySource:

@ParameterizedTest
@EmptySource
void isBlank_ShouldReturnTrueForEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

@EmptySource передает единственный пустой аргумент в аннотированный метод.

Для строковых аргументов переданное значение будет таким же простым, как пустая строка. Более того, этот источник параметров может предоставлять пустые значения для типов и массивов Collection.

Для передачи как нулевых, так и пустых значений мы можем использовать составную аннотацию @NullAndEmptySource:

@ParameterizedTest
@NullAndEmptySource
void isBlank_ShouldReturnTrueForNullAndEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

Как и в случае с @EmptySource, составная аннотация работает для строк, коллекций и массивов.

Чтобы передать параметризованному тесту еще несколько вариантов пустой строки, мы можем объединить @ValueSource, @NullSource и @EmptySource вместе:

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"  ", "\t", "\n"})
void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

4.3. Enum

Чтобы запустить тест с разными значениями из перечисления, мы можем использовать аннотацию @EnumSource.

Например, мы можем утверждать, что все номера месяцев находятся в диапазоне от 1 до 12:

@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {
    int monthNumber = month.getValue();
    assertTrue(monthNumber >= 1 && monthNumber <= 12);
}

Или мы можем отфильтровать несколько месяцев, используя атрибут name.

Мы также можем утверждать, что апрель, сентябрь, июнь и ноябрь имеют длину 30 дней:

@ParameterizedTest
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

По умолчанию в именах сохраняются только совпадающие значения перечисления.

«Мы можем исправить это, установив для атрибута режима значение EXCLUDE:

@ParameterizedTest
@EnumSource(
  value = Month.class,
  names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"},
  mode = EnumSource.Mode.EXCLUDE)
void exceptFourMonths_OthersAre31DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(31, month.length(isALeapYear));
}

В дополнение к литеральным строкам мы можем передать регулярное выражение атрибуту имен:

@ParameterizedTest
@EnumSource(value = Month.class, names = ".+BER", mode = EnumSource.Mode.MATCH_ANY)
void fourMonths_AreEndingWithBer(Month month) {
    EnumSet<Month> months =
      EnumSet.of(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER, Month.DECEMBER);
    assertTrue(months.contains(month));
}

Очень похоже на @ValueSource, @EnumSource применим только тогда, когда мы собираемся передать только один аргумент за выполнение теста.

4.4. Литералы CSV

Предположим, мы собираемся убедиться, что метод toUpperCase() из String генерирует ожидаемое значение в верхнем регистре. @ValueSource будет недостаточно.

Чтобы написать параметризованный тест для таких сценариев, мы должны

    Передать входное значение и ожидаемое значение в метод тестирования Вычислить фактический результат с этими входными значениями Подтвердить фактическое значение с ожидаемым значением

Итак , нам нужны источники аргументов, способные передавать несколько аргументов.

@CsvSource является одним из таких источников:

@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

@CsvSource принимает массив значений, разделенных запятыми, и каждая запись массива соответствует строке в файле CSV.

Этот источник каждый раз берет одну запись массива, разделяет ее запятой и передает каждый массив аннотированному тестовому методу как отдельные параметры.

По умолчанию запятая является разделителем столбцов, но мы можем настроить его с помощью атрибута разделителя:

@ParameterizedTest
@CsvSource(value = {"test:test", "tEst:test", "Java:java"}, delimiter = ':')
void toLowerCase_ShouldGenerateTheExpectedLowercaseValue(String input, String expected) {
    String actualValue = input.toLowerCase();
    assertEquals(expected, actualValue);
}

Теперь это значение, разделенное двоеточием, так что все еще CSV.

4.5. Файлы CSV

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

Например, мы могли бы использовать файл CSV следующим образом:

input,expected
test,TEST
tEst,TEST
Java,JAVA

Мы можем загрузить файл CSV и игнорировать столбец заголовка с помощью @CsvFileSource:

@ParameterizedTest
@CsvFileSource(resources = "/data.csv", numLinesToSkip = 1)
void toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile(
  String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

Атрибут resources представляет ресурсы файла CSV на пути к классам для чтения. И мы можем передать ему несколько файлов.

Атрибут numLinesToSkip представляет количество строк, которые нужно пропустить при чтении файлов CSV. По умолчанию @CsvFileSource не пропускает строки, но эта функция обычно полезна для пропуска строк заголовков, как мы сделали здесь.

Так же, как и простой @CsvSource, разделитель настраивается с помощью атрибута delimiter.

В дополнение к разделителю столбцов у нас есть следующие возможности:

    Разделитель строк можно настроить с помощью атрибута lineSeparator — новая строка является значением по умолчанию. Кодировку файла можно настроить с помощью атрибута encoding — значение по умолчанию — UTF-8.

4.6. Метод

Источники аргументов, которые мы рассмотрели до сих пор, несколько просты и имеют одно ограничение. С их помощью трудно или невозможно передать сложные объекты.

Одним из подходов к предоставлению более сложных аргументов является использование метода в качестве источника аргументов.

Давайте проверим метод isBlank с помощью @MethodSource:

@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

Имя, которое мы указываем для @MethodSource, должно соответствовать существующему методу.

Итак, давайте теперь напишем providerStringsForIsBlank, статический метод, который возвращает поток аргументов:

private static Stream<Arguments> provideStringsForIsBlank() {
    return Stream.of(
      Arguments.of(null, true),
      Arguments.of("", true),
      Arguments.of("  ", true),
      Arguments.of("not blank", false)
    );
}

Здесь мы буквально возвращаем поток аргументов, но это не является строгим требованием. Например, мы можем вернуть любые другие интерфейсы, похожие на коллекции, такие как List.

Если мы собираемся предоставить только один аргумент для каждого вызова теста, то нет необходимости использовать абстракцию Arguments:

@ParameterizedTest
@MethodSource // hmm, no method name ...
void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {
    assertTrue(Strings.isBlank(input));
}

private static Stream<String> isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() {
    return Stream.of(null, "", "  ");
}

Когда мы не указываем имя для @MethodSource, JUnit будет искать исходный метод с тем же именем, что и тестовый метод.

Иногда бывает полезно разделить аргументы между разными тестовыми классами. В этих случаях мы можем ссылаться на исходный метод вне текущего класса по его полному имени:

class StringsUnitTest {

    @ParameterizedTest
    @MethodSource("com.baeldung.parameterized.StringParams#blankStrings")
    void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {
        assertTrue(Strings.isBlank(input));
    }
}

public class StringParams {

    static Stream<String> blankStrings() {
        return Stream.of(null, "", "  ");
    }
}

Используя формат FQN#methodName, мы можем ссылаться на внешний статический метод.

4.7. Пользовательский поставщик аргументов

Другой продвинутый подход к передаче тестовых аргументов заключается в использовании пользовательской реализации интерфейса с именем ArgumentsProvider:

class BlankStringsArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of(
          Arguments.of((String) null), 
          Arguments.of(""), 
          Arguments.of("   ") 
        );
    }
}

Затем мы можем аннотировать наш тест аннотацией @ArgumentsSource для использования этого пользовательского поставщика: ~~ ~

@ParameterizedTest
@ArgumentsSource(BlankStringsArgumentsProvider.class)
void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider(String input) {
    assertTrue(Strings.isBlank(input));
}

Давайте сделаем пользовательский провайдер более удобным API для использования с пользовательской аннотацией.

4.8. Пользовательская аннотация

Предположим, мы хотим загрузить аргументы теста из статической переменной:

static Stream<Arguments> arguments = Stream.of(
  Arguments.of(null, true), // null strings should be considered blank
  Arguments.of("", true),
  Arguments.of("  ", true),
  Arguments.of("not blank", false)
);

@ParameterizedTest
@VariableSource("arguments")
void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource(
  String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

«

«На самом деле JUnit 5 этого не обеспечивает. Однако мы можем свернуть наше собственное решение.

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(VariableArgumentsProvider.class)
public @interface VariableSource {

    /**
     * The name of the static variable
     */
    String value();
}

Во-первых, мы можем создать аннотацию:

    Затем нам нужно каким-то образом использовать детали аннотации и предоставить тестовые аргументы. JUnit 5 предоставляет две абстракции для достижения этих целей:

AnnotationConsumer для использования сведений об аннотациях ArgumentsProvider для предоставления тестовых аргументов

class VariableArgumentsProvider 
  implements ArgumentsProvider, AnnotationConsumer<VariableSource> {

    private String variableName;

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return context.getTestClass()
                .map(this::getField)
                .map(this::getValue)
                .orElseThrow(() -> 
                  new IllegalArgumentException("Failed to load test arguments"));
    }

    @Override
    public void accept(VariableSource variableSource) {
        variableName = variableSource.value();
    }

    private Field getField(Class<?> clazz) {
        try {
            return clazz.getDeclaredField(variableName);
        } catch (Exception e) {
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private Stream<Arguments> getValue(Field field) {
        Object value = null;
        try {
            value = field.get(null);
        } catch (Exception ignored) {}

        return value == null ? null : (Stream<Arguments>) value;
    }
}

Итак, теперь нам нужно заставить класс VariableArgumentsProvider читать из указанной статической переменной и возвращать ее значение в качестве тестовых аргументов:

И это работает как шарм.

5. Преобразование аргументов

5.1. Неявное преобразование

@ParameterizedTest
@CsvSource({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) // Pssing strings
void someMonths_Are30DaysLongCsv(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

Давайте перепишем один из этих @EnumTests с @CsvSource:

Кажется, что это не должно работать, но каким-то образом оно работает.

JUnit 5 преобразует аргументы String в указанный тип перечисления. Для поддержки подобных случаев использования JUnit Jupiter предоставляет ряд встроенных неявных преобразователей типов.

    Процесс преобразования зависит от объявленного типа каждого параметра метода. Неявное преобразование может преобразовывать экземпляры String в следующие типы:

UUID Locale LocalDate, LocalTime, LocalDateTime, Year, Month и т. д. File and Path URL и URI подклассы Enum

5.2. Явное преобразование

Иногда нам нужно предоставить пользовательский и явный преобразователь для аргументов.

Предположим, мы хотим преобразовать строки в формате гггг/мм/дд в экземпляры LocalDate.

class SlashyDateConverter implements ArgumentConverter {

    @Override
    public Object convert(Object source, ParameterContext context)
      throws ArgumentConversionException {
        if (!(source instanceof String)) {
            throw new IllegalArgumentException(
              "The argument should be a string: " + source);
        }
        try {
            String[] parts = ((String) source).split("/");
            int year = Integer.parseInt(parts[0]);
            int month = Integer.parseInt(parts[1]);
            int day = Integer.parseInt(parts[2]);

            return LocalDate.of(year, month, day);
        } catch (Exception e) {
            throw new IllegalArgumentException("Failed to convert", e);
        }
    }
}

Во-первых, нам нужно реализовать интерфейс ArgumentConverter:

@ParameterizedTest
@CsvSource({"2018/12/25,2018", "2019/02/11,2019"})
void getYear_ShouldWorkAsExpected(
  @ConvertWith(SlashyDateConverter.class) LocalDate date, int expected) {
    assertEquals(expected, date.getYear());
}

Затем мы должны обратиться к преобразователю через аннотацию @ConvertWith:

6. Средство доступа к аргументу

По умолчанию каждый аргумент предоставленный параметризованному тесту, соответствует одному параметру метода. Следовательно, при передаче нескольких аргументов через источник аргументов сигнатура тестового метода становится очень большой и запутанной.

Одним из подходов к решению этой проблемы является инкапсуляция всех переданных аргументов в экземпляр ArgumentsAccessor и извлечение аргументов по индексу и типу.

class Person {

    String firstName;
    String middleName;
    String lastName;
    
    // constructor

    public String fullName() {
        if (middleName == null || middleName.trim().isEmpty()) {
            return String.format("%s %s", firstName, lastName);
        }

        return String.format("%s %s %s", firstName, middleName, lastName);
    }
}

Рассмотрим наш класс Person:

@ParameterizedTest
@CsvSource({"Isaac,,Newton,Isaac Newton", "Charles,Robert,Darwin,Charles Robert Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(ArgumentsAccessor argumentsAccessor) {
    String firstName = argumentsAccessor.getString(0);
    String middleName = (String) argumentsAccessor.get(1);
    String lastName = argumentsAccessor.get(2, String.class);
    String expectedFullName = argumentsAccessor.getString(3);

    Person person = new Person(firstName, middleName, lastName);
    assertEquals(expectedFullName, person.fullName());
}

Чтобы протестировать метод fullName(), мы передадим четыре аргумента: firstName, middleName, lastName и ожидаемое fullName. Мы можем использовать ArgumentsAccessor для получения тестовых аргументов вместо объявления их как параметров метода:

    Здесь мы инкапсулируем все переданные аргументы в экземпляр ArgumentsAccessor, а затем в теле тестового метода извлекаем каждый переданный аргумент. со своим индексом. Помимо того, что это просто метод доступа, преобразование типов поддерживается с помощью методов get*:

getString(index) извлекает элемент по определенному индексу и преобразует его в String — то же верно и для примитивных типов. get(index) просто извлекает элемент по определенному индексу как объект. get(index, type) извлекает элемент по определенному индексу и преобразует его в заданный тип.

7. Агрегатор аргументов

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

class PersonAggregator implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context)
      throws ArgumentsAggregationException {
        return new Person(
          accessor.getString(1), accessor.getString(2), accessor.getString(3));
    }
}

Для этого мы реализуем интерфейс ArgumentsAggregator:

@ParameterizedTest
@CsvSource({"Isaac Newton,Isaac,,Newton", "Charles Robert Darwin,Charles,Robert,Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(
  String expectedFullName,
  @AggregateWith(PersonAggregator.class) Person person) {

    assertEquals(expectedFullName, person.fullName());
}

А затем ссылаемся на него через аннотацию @AggregateWith:

PersonAggregator принимает последние три аргумента и создает экземпляр класса Person из их.

8. Настройка отображаемых имен

├─ someMonths_Are30DaysLongCsv(Month)
│     │  ├─ [1] APRIL
│     │  ├─ [2] JUNE
│     │  ├─ [3] SEPTEMBER
│     │  └─ [4] NOVEMBER

По умолчанию отображаемое имя для параметризованного теста содержит индекс вызова вместе со строковым представлением всех переданных аргументов:

@ParameterizedTest(name = "{index} {0} is 30 days long")
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

Однако мы можем настроить это отображение с помощью Атрибут name аннотации @ParameterizedTest:

├─ someMonths_Are30DaysLong(Month)
│     │  ├─ 1 APRIL is 30 days long
│     │  ├─ 2 JUNE is 30 days long
│     │  ├─ 3 SEPTEMBER is 30 days long
│     │  └─ 4 NOVEMBER is 30 days long

Апрель длится 30 дней, безусловно, это более читаемое отображаемое имя:

    При настройке отображаемого имени доступны следующие заполнители:

{index} будет заменен индексом вызова. Проще говоря, индекс вызова для первого выполнения равен 1, для второго — 2 и так далее. {arguments} – это полный список аргументов, разделенных запятыми. {0}, {1}, … — это заполнители для отдельных аргументов.

«9. Заключение

В этой статье мы рассмотрели основные моменты параметризованных тестов в JUnit 5.

Мы узнали, что параметризованные тесты отличаются от обычных тестов двумя аспектами: они помечены @ParameterizedTest и им нужен источник для заявленных аргументов.

Кроме того, к настоящему времени мы должны знать, что JUnit предоставляет некоторые средства для преобразования аргументов в настраиваемые типы целей или для настройки имен тестов.