
Переход от 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".
 
           
 
valery1707
Я понимаю что вы хотели показать именно реализацию кастомных расширений, но для реализация таких тестов уже есть встроенные решения: