Параметризация тестов: JUnit

Начиная с 4-й версии, в JUnit введена параметризация тестов. Она позволяет значительно уменьшить избыточность кода. Разберемся, как работать с параметризацией.

По определению, параметризация это функция “позволяющая запускать тестовый метод каждый раз с разными параметрами”.

В конце узнаем о некоторых полезностях, которые можно применять в своей практике, на практическом примере.

Что нужно

Для начала нужно разбираться в Spring Boot и JUnit. 

Итак, с чем будем работать:

  • Spring Boot 2.5.3
  • Maven
  • JUnit 5
  • Java 8+

Добавляем зависимости JUnit

Настраиваем проект (возможно стоит ознакомиться с указаниями, как это делать), далее добавляем в pom.xml такие строчки:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>org.junit.jupiter</groupId>
   <artifactId>junit-jupiter-params</artifactId>
</dependency>

Первая из зависимостей включает не только JUnit, но и еще пару библиотек, которые которые могут понадобиться для тестирования нашего приложения: mockito и hamcrest. Hamcrest будет задействован для получения провайдеров аргументов (далее объясню, что это).

Cценарий

Один аргумент

Представим, что надо работать с функцией, которая проверяет введенный возраст, больше ли он, чем 21 год.

public boolean isOlderThan21(int age) throws Exception {
        if (age < 21) {
            throw new Exception();
        }
        return true;
}

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

Сейчас нам нужно ввести аннотации @ParameterizedTest и @ValueSource, чтобы JUnit “понимал”, что будут передаваться разные аргументы. Такие аннотации позволяют применять аргументы самых разных типов, а именно: short, byte, int, long, float, double, char, boolean, string, и даже class.

@ParameterizedTest
    @ValueSource(ints = {10, 0, 15, 20})
    void givenAgeLessThan21_ShouldThrowException(int age) {
        assertThrows(Exception.class, () -> {
            example.isOlderThan21(age);
        });
    }

Мы передали 4 аргумента, и это значит, что тест запустится 4 раза. В первой итерации переменная age примет значение 10, далее 0, 15, 20.

После завершения тестов увидим в окне Test Results результат каждой итерации.

Несколько аргументов

Теперь создадим простую функцию, умножающую два целых числа.

public int calculateTotalPrice(int productPrice, int quantity) {
    return productPrice * quantity;
}

Представим, что нужно протестировать эту функцию, передавая в нее два аргумента со значением 2. Их умножение должно быть равно 4 (если правила арифметики не поменялись).

class ExampleTest {

    private Example example;

    @BeforeEach
    void setup() {
        example = new Example();
    }

    @Test
    void givenTwoIntegersEqualsTo2_totalPriceShouldReturn4() {
        int value = example.calculateTotalPrice(2, 2);

        assertEquals(4, value);
    }

Пока видим, что в тесте без проблем. Но есть вещи, которые нужно иметь в виду.

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

Поэтому и нужны параметризованные тесты с несколькими аргументами. Смотрим на код ниже.

@ParameterizedTest
    @CsvSource({
        "2, 3, 6",
        "0, 10, 0",
        "-5, 8, -40",
        "3, -10, -30",
        "-3, -13, 39"
    })
    void calculateTotalPriceShouldWorkGivenDifferentArguments(
      int productPrice, int quantity, int totalExpected) {
        int totalCalculated = example.calculateTotalPrice(productPrice, quantity);

        assertEquals(totalExpected, totalCalculated);
    }

Здесь видим, что есть какое-то @CsvSource вслед за @ParameterizedTest.

Первая итерация получит аргументы 2, 3, 6, которые “привязаны” к сигнатуре метода, простым их объявлением.

Важная вещь которую нужно иметь в виду: аргументы “привязываются” точно в том порядке, в котором передавались. А именно:

  • productPrice = 2
  • quantity = 3
  • totalExpected = 6

Здесь может возникнуть вопрос, почему передается String, если метод calculatedTotalPrice предполагает только int-аргументы? А вот здесь видна полезность JUnit.

Иногда это называют “преобразованием аргументов”, или приведением их типов (Argument Conversion). Эта тема выходит за рамки обзорной статьи, но ее стоит упомянуть. 

Кратко посмотрим, что делает такое преобразование:

public boolean isPaymentValid(LocalDate datePaid) throws Exception {
        if(datePaid.compareTo(LocalDate.now()) > 0) {
            throw new Exception();
        }
        return true;
    }

@ParameterizedTest
    @ValueSource(strings = {"2021-08-15", "2020-12-17", "2020-02-27"})
    void testingImplicitConversion(LocalDate date) throws Exception {
        assertTrue(example.isPaymentValid(date));
    }

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

Работаем с CSV-файлом

В предыдущем примере мы задействовали @CsvSource, несмотря на то что передали как аргумент строковое значение (String). Но бывает проблема, когда данных очень много, или если нужно тестировать много сценариев. Это превращается в хаос, тест нечитаемый.

Но есть способ сделать CSV-файл “правильным”.

Сперва создадим файл testData.csv в папке src/test/resources, и вставим в него такие строчки:

2, 3, 6
0, 10, 0
-5, 8, -40
3, -10, -30
-3, -13, 39
10, 15, 150
9, 9, 81

Мы добавили аннотацию @CsvFileSource, имеющую аргумент “resources”, который должен быть добавлен в адрес CSV-файла. (Если этот файл расположен в папке по умолчанию — src/test/resources — то можно указать только его название с расширением, файл будет найден автоматически).

@ParameterizedTest
@CsvFileSource(resources = "/testData.csv")
void calculateTotalPriceShouldWorkGivenDifferentArgumentsFromCSVFile(
		int productPrice, int quantity, int totalExpected) {
    int totalCalculated = example.calculateTotalPrice(productPrice, quantity);

    assertEquals(totalExpected, totalCalculated);
}

Источник Enum-данных

Аннотация @EnumSource говорит о передаче данных из Enum. Обычно это бывает при тестировании бизнес-логики.

public enum Sizes {
    EXTRA_SMALL,
    SMALL,
    MEDIUM,
    LARGE,
    EXTRA_LARGE
}

@ParameterizedTest
    @EnumSource(Sizes.class)
    void enumElementsShouldNotBeNull(Sizes sizes) {
        assertNotNull(sizes);
    }

Аннотация MethodSource

Иногда может понадобиться передать несколько параметров (их набор) в несколько тестов. Для этого придумана аннотация @MethodSource.

Она загружает внешнюю функцию, выступающей в роли “поставщика аргументов”. Так можно передать набор параметров в несколько тестов.

Функция DataProvider может добавляться к любому пакету, нужно ввести полный путь к нему.

//Any package you need
package com.vandeilson.testes.material;

private static Stream<Arguments> dataProvider() {
    return Stream.of(
        Arguments.of("Vandeilson", "Nobre"),
        Arguments.of("David", "Bowie"),
        Arguments.of("Bruce", "Dickinson")
                    );
}

//In the test package
@ParameterizedTest
    @MethodSource("com.vandeilson.testes.material.Example#dataProvider")
    void elementMustHaveSizeEqual2(String name, String surname) {
        assertTrue(!name.isEmpty() && !surname.isEmpty());
    }

Здесь все, что нужно сделать, это декларировать пакет с этой функцией, вставить символ хэштега #, и затем название функции, и это будет аргумент аннотации.

Бонус

Можно кастомизировать отображаемое имя тестов. 

Как мы помним, в нашем тесте было:

@ParameterizedTest
    @CsvSource({
        "2, 3, 6",
        "0, 10, 0",
        "-5, 8, -40",
        "3, -10, -30",
        "-3, -13, 39"
    })
    void calculateTotalPriceShouldWorkGivenDifferentArguments(
      int productPrice, int quantity, int totalExpected) {
        int totalCalculated = example.calculateTotalPrice(productPrice, quantity);

        assertEquals(totalExpected, totalCalculated);
    }

При его запуске увидим следующее:

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

  • {index}. Отображает итерацию (начинается с 1).
  • {0}, {1}. Отображает имя переменной. Применяется, когда есть несколько параметров.

Все что нужно сделать, это передать аргумент name внутри @parametrizedTest.

@ParameterizedTest(name = "Iteration #{index} -> Product Price = {0}, Quantity = {1} and the multiplication value is {2}")
    @CsvSource({
        "2, 3, 6",
        "0, 10, 0",
        "-5, 8, -40",
        "3, -10, -30",
        "-3, -13, 39"
    })
    void calculateTotalPriceShouldWorkGivenDifferentArguments(int productPrice, int quantity, int totalExpected) {
        int totalCalculated = example.calculateTotalPrice(productPrice, quantity);

        assertEquals(totalExpected, totalCalculated);
    }

Надеемся, материал был не только сложным, но и полезным. 

***

В.Нобрэ — тестировщик в компании PicPay (финтех)

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас