Выходя за рамки JUnit. Создаем сложные расширения

от автора

Переход от JUnit4 к новой версии во многом изменил способ расширения функциональных возможностей тестов. Напомню, что в JUnit4 основным механизмом расширения были правила (Rule), которые могли обернуть выполнение теста в дополнительную логическую обработку (например, в реализации абстрактного класса ExternalResource встраивали два дополнительных вызова методов инициализации (который также мог возвращать объект для взаимодействия с создаваемым окружением, например обертку вокруг Android Activity) и финализации (вызывается после выполнения теста и используется для очистки ресурсов). Модель JUnit 5 существенно дополнена и в этой статье мы рассмотрим как можно создавать собственные расширения для JUnit Platform.

Начнем с рассмотрения простого примера и для сравнения возьмем пример расширения для JUnit 4. Создадим функцию с одним статическим методом и напишем простой тест для нее:

package org.example;  public class Main {     public static int sum(int a, int b) {         return a+b;     } }
import org.example.Main; import org.junit.Test;  import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo;  public class MainTest {     @Test     public void testSum() {         assertThat(Main.sum(3,5), equalTo(8));     } } 

В build.gradle добавим зависимости для junit4 и укажем его как тестовый движок (а также будем использовать hamcrest для описания тестовых утверждений):

plugins {     id 'java' }  group 'org.example' version '1.0-SNAPSHOT'  repositories {     mavenCentral() }  dependencies {     testImplementation 'org.hamcrest:hamcrest-core:2.2'     testImplementation 'junit:junit:4.13.2'     testImplementation 'org.hamcrest:hamcrest-library:2.2' }  test {     useJUnit() }

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

class TestData {     int num1;     int num2;     int sum;      TestData(int num1, int num2, int sum) {         this.num1 = num1;         this.num2 = num2;         this.sum = sum;     } }
class FileSourceRule implements TestRule {      ArrayList<TestData> data = new ArrayList<>();      InputStream inputStream;      FileSourceRule(InputStream inputStream) {         this.inputStream = inputStream;     }      @Override     public Statement apply(Statement base, Description description) {         return new Statement() {             @Override             public void evaluate() throws Throwable {                 //load data                 BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));                 reader.lines().forEach((Consumer) o -> {                     String[] split = o.toString().split(" ");                     int num1 = Integer.parseInt(split[0]);                     int num2 = Integer.parseInt(split[1]);                     int sum = Integer.parseInt(split[2]);                     data.add(new TestData(num1, num2, sum));                 });                 base.evaluate();                 inputStream.close();             }         };     } }

Альтернативно можно использовать базовый абстрактный класс ExternalResource:

class FileSourceRule extends ExternalResource {      ArrayList<TestData> data = new ArrayList<>();      InputStream inputStream;      FileSourceRule(InputStream inputStream) {         this.inputStream = inputStream;     }      @Override     protected void before() throws Throwable {         BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));         reader.lines().forEach((Consumer) o -> {             String[] split = o.toString().split(" ");             int num1 = Integer.parseInt(split[0]);             int num2 = Integer.parseInt(split[1]);             int sum = Integer.parseInt(split[2]);             data.add(new TestData(num1, num2, sum));         });     }      @Override     protected void after() {         try {             inputStream.close();         } catch (IOException e) {             throw new RuntimeException(e);         }     } } 

Теперь можно добавить созданное правило к нашему тесту и получить возможность загружать и использовать тестовые данные из внешнего файла (расположен в src/test/resources):

public class MainTest {      @Rule     public FileSourceRule rule = new FileSourceRule(ClassLoader.getSystemResourceAsStream("test.dat"));          @Test     public void testSum() {         rule.data.forEach(data -> assertThat(Main.sum(data.num1, data.num2), equalTo(data.sum)));     } }

Теперь перейдем к рассмотрению расширений в JUnit Platform и попробуем реализовать эту задачу новым способом. Прежде всего нужно отметить, что новый JUnit Platform предполагает возможность создания разных представлений для описания тестов и возможно как использование синтаксиса JUnit 4 (с установленным JUnit Vintage) или нового синтаксиса JUnit 5, но также возможны совсем другие реализации тестовых движков (например те, которые читают текстовые файлы и запускают запросы к API в соответствии с описанным сценарием). Сейчас мы рассмотрим только модель расширений JUnit5.

Добавим поддержку JUnit5 в gradle:

plugins {     id 'java' }  group 'org.example' version '1.0-SNAPSHOT'  repositories {     mavenCentral() }  dependencies {     testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'     testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' }  test {     useJUnitPlatform() }

И перепишем тест на использование собственных утверждений junit-jupiter:

import org.example.Main; import org.junit.jupiter.api.Test;  import static org.junit.jupiter.api.Assertions.assertEquals;  public class TestMain {     @Test     void testSum() {         assertEquals(Main.sum(3,5), 8);     } }

Расширения в JUnit 5 реализуются с использованием базового интерфейса-маркера Extension и регистрируются через аннотацию @ExtendWith(ExtensionClass.class) с переопределением методов жизненного цикла:

  • before*, after*, где вместо * может использоваться All для однократного выполнения, Each для каждого теста, TestExecution для выполнения теста — интерфейсы Before*Callback и After*Callback;

  • определение условий выполнения теста через переопределение evaluateExecutionCondition — интерфейс ExecuteCondition

  • получение значений для параметризированных тестов через supportsParameter/resolveParameter — в интерфейсе ParameterResolver

  • наблюдение за результатами тестов (testDisabled, testSuccessful, testAborted, testFailed) — в интерфейсе TestWatcher.

  • многократный запуск (или запуск параметрических тестов) — supportsTestTemplate (поддержка запуска в нескольких контекстах) и provideTestTemplateInvocationContexts (поставщик контекстов) — в интерфейсе TestTemplateInvocationContextProvider

В нашем случае для реализации загрузки данных до теста можно использовать переопределение beforeAll (для инициализации массива данных) и afterAll (для финализации), но для начала добавим более простую реализацию логирования запуска и завершения тестов.

class LogExtension implements BeforeEachCallback, AfterEachCallback, TestWatcher {      @Override     public void afterEach(ExtensionContext context) {         System.out.println("Finished test " + context.getDisplayName());     }      @Override     public void beforeEach(ExtensionContext context) {         System.out.println("Started test " + context.getDisplayName());     }      @Override     public void testSuccessful(ExtensionContext context) {         System.out.println("Test OK: "+context.getRequiredTestMethod().getName());     } }

Для подключения расширения добавим аннотацию @ExtendWith(LogExtension.class) перед определением класса теста (или тестового метода). Теперь попробуем добавить реализацию метода для загрузки данных из внешнего источника:

class FileSourceExtension implements BeforeAllCallback, AfterAllCallback {     ArrayList<TestData> data = new ArrayList<>();      InputStream inputStream;      FileSourceExtension(InputStream inputStream) {         this.inputStream = inputStream;     }      @Override     public void beforeAll(ExtensionContext context) throws Exception {         BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));         reader.lines().forEach((Consumer) o -> {             String[] split = o.toString().split(" ");             int num1 = Integer.parseInt(split[0]);             int num2 = Integer.parseInt(split[1]);             int sum = Integer.parseInt(split[2]);             data.add(new TestData(num1, num2, sum));         });     }      @Override     public void afterAll(ExtensionContext context) throws Exception {         inputStream.close();     } }

Но здесь возникнет проблема при установке через @ExtendWith, поскольку предполагается передача значения в конструктор и нужно сохранить объект-расширения для доступа к данным. В этом случае можно использовать аннотацию @RegisterExtension перед статическим полем с созданием объекта (аналогично аннотации @Rule для JUnit4):

class FileSourceExtension implements BeforeAllCallback, AfterAllCallback {     ArrayList<TestData> data = new ArrayList<>();      InputStream inputStream;      FileSourceExtension(InputStream inputStream) {         System.out.println("File source extension : " + inputStream);         this.inputStream = inputStream;     }      @Override     public void beforeAll(ExtensionContext context) {         BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));         reader.lines().forEach((Consumer) o -> {             String[] split = o.toString().split(" ");             int num1 = Integer.parseInt(split[0]);             int num2 = Integer.parseInt(split[1]);             int sum = Integer.parseInt(split[2]);             data.add(new TestData(num1, num2, sum));         });     }      @Override     public void afterAll(ExtensionContext context) throws Exception {         inputStream.close();     } }

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

@ExtendWith(LogExtension.class) public class TestMain {      @RegisterExtension     static FileSourceExtension dataSource = new FileSourceExtension(ClassLoader.getSystemResourceAsStream("test.dat"));      @Test     void testSum() {         dataSource.data.forEach(data -> assertEquals(Main.sum(data.num1, data.num2), data.sum));     } }

Для обмена данными методы расширения могут использовать store (доступен в ExtensionContext) и взаимодействовать с контекстом запуска тестов (например, получать текущий тестовый класс/метод).

Кроме поставки данных через параметры вызова метода также может быть создано расширение для генерации значений как параметров теста:

class FileSourceExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver {     ArrayList<TestData> data = new ArrayList<>();      Iterator<TestData> iterator = data.iterator();      TestData current;      InputStream inputStream;      FileSourceExtension(InputStream inputStream) {         this.inputStream = inputStream;     }      @Override     public void beforeAll(ExtensionContext context) {         BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));         reader.lines().forEach((Consumer) o -> {             String[] split = o.toString().split(" ");             int num1 = Integer.parseInt(split[0]);             int num2 = Integer.parseInt(split[1]);             int sum = Integer.parseInt(split[2]);             data.add(new TestData(num1, num2, sum));         });         iterator = data.iterator();         current = iterator.next();     }      @Override     public void afterAll(ExtensionContext context) throws Exception {         inputStream.close();     }      @Override     public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {         return true;     }      @Override     public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {         switch (parameterContext.getParameter().getName()) {             case "arg0":                 return current.num1;             case "arg1":                 return current.num2;             case "arg2":                 int sum = current.sum;                 if (iterator.hasNext()) {                     current = iterator.next();                 }                 return sum;             default:                 return null;         }     } }

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

@ExtendWith(LogExtension.class) public class TestMain {      @RegisterExtension     static FileSourceExtension dataSource = new FileSourceExtension(ClassLoader.getSystemResourceAsStream("test.dat"));      @TestTemplate     void testSum(int num1, int num2, int sum) {         System.out.println(num1 + ":" + num2 + " : " + sum);     } }
class FileSourceExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver, TestTemplateInvocationContextProvider { //...   @Override     public boolean supportsTestTemplate(ExtensionContext context) {         return true;     }      @Override     public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {         return IntStream.range(0, data.size()).mapToObj((i) -> new ParametrizedTextInvocationContext(i));     }   } }  class ParametrizedTextInvocationContext implements TestTemplateInvocationContext {     int count;      ParametrizedTextInvocationContext(int count) {         this.count = count;     }      @Override     public String getDisplayName(int invocationIndex) {         return "Iteration "+count;     } }

Для контекста также может быть задано отображаемое имя (например, можно показывать номер итерации), оно будет отображаться в тестовом отчете. Также методы расширения могут взаимодействовать со сгенерированным отчетом через вызовы context.publishReportEntry. После запуска теста отчет может выглядеть подобным образом:

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

Статья подготовлена в преддверии старта курса Java QA Engineer. Professional. Также хочу поделиться с вами записью бесплатного вебинара по теме «Пишем тесты с использованием Selenide».


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/713908/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *