Переход от 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".

Комментарии (1)


  1. valery1707
    01.02.2023 10:47

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

    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.CsvFileSource;
    
    import static org.assertj.core.api.Assertions.assertThat;
    
    class MainTest {
        @ParameterizedTest(name = "sum({0}, {1}) == {2}")
        @CsvFileSource(
            delimiter = ' ',
            resources = {
                "/test.dat",
            })
        void test1(int a, int b, int expected) {
            assertThat(Main.sum(a, b)).isEqualTo(expected);
        }
    }