«1. Обзор

Динамическое тестирование — это новая модель программирования, представленная в JUnit 5. В этой статье мы рассмотрим, что такое динамические тесты и как их создавать.

Если вы новичок в JUnit 5, возможно, вы захотите ознакомиться с предварительной версией JUnit 5 и нашим основным руководством.

2. Что такое динамический тест?

Стандартные тесты, помеченные аннотацией @Test, являются статическими тестами, которые полностью определяются во время компиляции. DynamicTest — это тест, созданный во время выполнения. Эти тесты генерируются фабричным методом, снабженным аннотацией @TestFactory.

Метод @TestFactory должен возвращать Stream, Collection, Iterable или Iterator экземпляров DynamicTest. Возврат чего-либо еще приведет к JUnitException, поскольку недопустимые возвращаемые типы не могут быть обнаружены во время компиляции. Кроме того, метод @TestFactory не может быть статическим или приватным.

Динамические тесты выполняются иначе, чем стандартные @Tests, и не поддерживают обратные вызовы жизненного цикла. Это означает, что методы @BeforeEach и @AfterEach не будут вызываться для динамических тестов.

3. Создание динамических тестов

Во-первых, давайте рассмотрим различные способы создания динамических тестов.

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

Мы собираемся создать коллекцию DynamicTest:

@TestFactory
Collection<DynamicTest> dynamicTestsWithCollection() {
    return Arrays.asList(
      DynamicTest.dynamicTest("Add test",
        () -> assertEquals(2, Math.addExact(1, 1))),
      DynamicTest.dynamicTest("Multiply Test",
        () -> assertEquals(4, Math.multiplyExact(2, 2))));
}

Метод @TestFactory сообщает JUnit, что это фабрика для создания динамических тестов. Как мы видим, мы возвращаем только коллекцию DynamicTest. Каждый DynamicTest состоит из двух частей: имени теста или отображаемого имени и исполняемого файла.

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

Add test(dynamicTestsWithCollection())
Multiply Test(dynamicTestsWithCollection())

Тот же тест можно изменить, чтобы он возвращал Iterable, Iterator или Stream:

@TestFactory
Iterable<DynamicTest> dynamicTestsWithIterable() {
    return Arrays.asList(
      DynamicTest.dynamicTest("Add test",
        () -> assertEquals(2, Math.addExact(1, 1))),
      DynamicTest.dynamicTest("Multiply Test",
        () -> assertEquals(4, Math.multiplyExact(2, 2))));
}

@TestFactory
Iterator<DynamicTest> dynamicTestsWithIterator() {
    return Arrays.asList(
      DynamicTest.dynamicTest("Add test",
        () -> assertEquals(2, Math.addExact(1, 1))),
      DynamicTest.dynamicTest("Multiply Test",
        () -> assertEquals(4, Math.multiplyExact(2, 2))))
        .iterator();
}

@TestFactory
Stream<DynamicTest> dynamicTestsFromIntStream() {
    return IntStream.iterate(0, n -> n + 2).limit(10)
      .mapToObj(n -> DynamicTest.dynamicTest("test" + n,
        () -> assertTrue(n % 2 == 0)));
}

Обратите внимание, что если @TestFactory возвращает поток, он будет автоматически закрыт после выполнения всех тестов.

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

4. Создание потока динамических тестов

В демонстрационных целях рассмотрим DomainNameResolver, который возвращает IP-адрес, когда мы передаем доменное имя в качестве входных данных.

Для простоты давайте взглянем на высокоуровневый скелет нашего фабричного метода:

@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {

    // sample input and output
    List<String> inputList = Arrays.asList(
      "www.somedomain.com", "www.anotherdomain.com", "www.yetanotherdomain.com");
    List<String> outputList = Arrays.asList(
      "154.174.10.56", "211.152.104.132", "178.144.120.156");

    // input generator that generates inputs using inputList
    /*...code here...*/

    // a display name generator that creates a 
    // different name based on the input
    /*...code here...*/

    // the test executor, which actually has the 
    // logic to execute the test case
    /*...code here...*/

    // combine everything and return a Stream of DynamicTest
    /*...code here...*/
}

Здесь не так много кода, связанного с DynamicTest, кроме аннотации @TestFactory, которую мы уже знаком с.

Два списка ArrayList будут использоваться в качестве входных данных для DomainNameResolver и ожидаемых выходных данных соответственно.

Давайте теперь посмотрим на генератор ввода:

Iterator<String> inputGenerator = inputList.iterator();

Генератор ввода — это не что иное, как итератор строки. Он использует наш inputList и возвращает доменное имя одно за другим.

Генератор отображаемых имен довольно прост:

Function<String, String> displayNameGenerator 
  = (input) -> "Resolving: " + input;

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

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

Теперь давайте посмотрим на центральную часть нашего теста — код выполнения теста:

DomainNameResolver resolver = new DomainNameResolver();
ThrowingConsumer<String> testExecutor = (input) -> {
    int id = inputList.indexOf(input);
 
    assertEquals(outputList.get(id), resolver.resolveDomain(input));
};

Мы использовали ThrowingConsumer, который является @FunctionalInterface для написания тестового примера. Для каждого ввода, сгенерированного генератором данных, мы извлекаем ожидаемый результат из outputList и фактический вывод из экземпляра DomainNameResolver.

Теперь последняя часть — просто собрать все части и вернуть в виде потока DynamicTest:

return DynamicTest.stream(
  inputGenerator, displayNameGenerator, testExecutor);

Вот и все. Запуск теста отобразит отчет, содержащий имена, определенные нашим генератором отображаемых имен:

Resolving: www.somedomain.com(dynamicTestsFromStream())
Resolving: www.anotherdomain.com(dynamicTestsFromStream())
Resolving: www.yetanotherdomain.com(dynamicTestsFromStream())

5. Улучшение DynamicTest с помощью функций Java 8

«Тестовая фабрика, написанная в предыдущем разделе, может быть значительно улучшена с помощью возможностей Java 8. Результирующий код будет намного чище и может быть записан в меньшем количестве строк:

@TestFactory
Stream<DynamicTest> dynamicTestsFromStreamInJava8() {
        
    DomainNameResolver resolver = new DomainNameResolver();
        
    List<String> domainNames = Arrays.asList(
      "www.somedomain.com", "www.anotherdomain.com", "www.yetanotherdomain.com");
    List<String> outputList = Arrays.asList(
      "154.174.10.56", "211.152.104.132", "178.144.120.156");
        
    return inputList.stream()
      .map(dom -> DynamicTest.dynamicTest("Resolving: " + dom, 
        () -> {int id = inputList.indexOf(dom);
 
      assertEquals(outputList.get(id), resolver.resolveDomain(dom));
    }));       
}

Приведенный выше код имеет то же самое эффект, как тот, который мы видели в предыдущем разделе. inputList.stream().map() предоставляет поток входных данных (генератор входных данных). Первый аргумент для dynamicTest() — это наш генератор отображаемого имени («Resolving: + dom»), а второй аргумент, лямбда, — наш исполнитель теста.

Вывод будет таким же, как и в предыдущем разделе.

6. Дополнительный пример

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

@TestFactory
Stream<DynamicTest> dynamicTestsForEmployeeWorkflows() {
    List<Employee> inputList = Arrays.asList(
      new Employee(1, "Fred"), new Employee(2), new Employee(3, "John"));
        
    EmployeeDao dao = new EmployeeDao();
    Stream<DynamicTest> saveEmployeeStream = inputList.stream()
      .map(emp -> DynamicTest.dynamicTest(
        "saveEmployee: " + emp.toString(), 
          () -> {
              Employee returned = dao.save(emp.getId());
              assertEquals(returned.getId(), emp.getId());
          }
    ));
        
    Stream<DynamicTest> saveEmployeeWithFirstNameStream 
      = inputList.stream()
      .filter(emp -> !emp.getFirstName().isEmpty())
      .map(emp -> DynamicTest.dynamicTest(
        "saveEmployeeWithName" + emp.toString(), 
        () -> {
            Employee returned = dao.save(emp.getId(), emp.getFirstName());
            assertEquals(returned.getId(), emp.getId());
            assertEquals(returned.getFirstName(), emp.getFirstName());
        }));
        
    return Stream.concat(saveEmployeeStream, 
      saveEmployeeWithFirstNameStream);
}

Методу save(Long) нужны только идентификатор сотрудника. Следовательно, он использует все экземпляры Employee. Для метода save(Long, String) требуется имя firstName, кроме employeeId. Следовательно, он отфильтровывает экземпляры Employee без firstName.

Наконец, мы объединяем оба потока и возвращаем все тесты как один поток.

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

saveEmployee: Employee 
  [id=1, firstName=Fred](dynamicTestsForEmployeeWorkflows())
saveEmployee: Employee 
  [id=2, firstName=](dynamicTestsForEmployeeWorkflows())
saveEmployee: Employee 
  [id=3, firstName=John](dynamicTestsForEmployeeWorkflows())
saveEmployeeWithNameEmployee 
  [id=1, firstName=Fred](dynamicTestsForEmployeeWorkflows())
saveEmployeeWithNameEmployee 
  [id=3, firstName=John](dynamicTestsForEmployeeWorkflows())

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

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

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

В JUnit 5 предпочтение отдается расширениям, а не функциям. В результате основная цель динамических тестов — предоставить точку расширения для сторонних фреймворков или расширений.

Вы можете узнать больше о других возможностях JUnit 5 в нашей статье о повторных тестах в JUnit 5.

Не забудьте проверить полный исходный код этой статьи на GitHub.