Перейти к основному содержимому

Руководство по параметризованным тестам JUnit 5

· 14 мин. чтения

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 будет запускать этот тест два раза и каждый раз присваивать параметру метода один аргумент из массива.

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

  • короткий (с атрибутом шорты )
  • байт ( атрибут байты )
  • int ( атрибут целых чисел )
  • длинный ( длинный атрибут)
  • float ( атрибут float )
  • двойной ( двойной атрибут)
  • символ ( атрибут символов )
  • java.lang.String ( атрибут строки )
  • java.lang.Class ( атрибут классов )

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

Прежде чем идти дальше, обратите внимание, что мы не передавали 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 , составленная аннотация работает для String , Collection и arrays .

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

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

4.3. перечисление

Чтобы запустить тест с разными значениями из перечисления, мы можем использовать аннотацию @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));
}

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

@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);
}

Атрибут ресурсов представляет ресурсы файла 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 , статический метод , который возвращает Stream of Argument s `` :

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.foreach.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 для предоставления тестовых аргументов

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

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;
}
}

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

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

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

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

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

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

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

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

  • UUID
  • Регион
  • LocalDate , LocalTime , LocalDateTime , Year , Month и т. д.
  • Файл и путь
  • URL и URI
  • Подклассы перечисления

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

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

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

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

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);
}
}
}

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

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

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

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

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

Давайте рассмотрим наш класс Person :

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);
}
}

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

@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());
}

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

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

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

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

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

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));
}
}

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

@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());
}

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

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

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

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

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

@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));
}

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

├─ 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

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

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

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

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

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

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

Как обычно, примеры кода доступны в нашем проекте на GitHub , так что обязательно ознакомьтесь с ним.