Выходя за рамки 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 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 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 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 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 data = new ArrayList<>();
Iterator 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 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».