Перейти к основному содержимому

Руководство по расширениям JUnit 5

· 10 мин. чтения

1. Обзор

В этой статье мы рассмотрим модель расширения в библиотеке тестирования JUnit 5. Как следует из названия, целью расширений Junit 5 является расширение поведения тестовых классов или методов , и их можно повторно использовать для нескольких тестов.

До Junit 5 версия библиотеки JUnit 4 использовала два типа компонентов для расширения теста: средства выполнения тестов и правила. Для сравнения, JUnit 5 упрощает механизм расширения, вводя единую концепцию: Extension API.

2. Модель расширения JUnit 5

Расширения JUnit 5 связаны с определенным событием при выполнении теста, называемым точкой расширения. Когда достигается определенная фаза жизненного цикла, механизм JUnit вызывает зарегистрированные расширения.

Можно использовать пять основных типов точек расширения:

  • постобработка тестового экземпляра
  • условное выполнение теста
  • обратные вызовы жизненного цикла
  • разрешение параметра
  • Обработка исключений

Мы рассмотрим каждый из них более подробно в следующих разделах.

3. Зависимости Maven

Во-первых, давайте добавим зависимости проекта, которые нам понадобятся для наших примеров. Основная библиотека JUnit 5, которая нам понадобится, это junit-jupiter-engine :

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.1</version>
<scope>test</scope>
</dependency>

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

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.8.2</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.196</version>
</dependency>

Последние версии junit-jupiter-engine , h2 и log4j-core можно загрузить с Maven Central.

4. Создание расширений JUnit 5

Чтобы создать расширение JUnit 5, нам нужно определить класс, который реализует один или несколько интерфейсов, соответствующих точкам расширения JUnit 5. Все эти интерфейсы расширяют основной интерфейс расширения , который является только интерфейсом маркера.

4.1. Расширение TestInstancePostProcessor

Этот тип расширения выполняется после создания экземпляра теста. Интерфейсом для реализации является TestInstancePostProcessor , который имеет метод postProcessTestInstance() для переопределения.

Типичным вариантом использования этого расширения является внедрение зависимостей в экземпляр. Например, давайте создадим расширение, которое создает экземпляр объекта логгера , а затем вызывает метод setLogger() для тестового экземпляра:

public class LoggingExtension implements TestInstancePostProcessor {

@Override
public void postProcessTestInstance(Object testInstance,
ExtensionContext context) throws Exception {
Logger logger = LogManager.getLogger(testInstance.getClass());
testInstance.getClass()
.getMethod("setLogger", Logger.class)
.invoke(testInstance, logger);
}
}

Как видно выше, метод postProcessTestInstance() предоставляет доступ к тестовому экземпляру и вызывает метод setLogger() тестового класса с использованием механизма отражения.

4.2. Условное выполнение теста

JUnit 5 предоставляет тип расширения, которое может контролировать, следует ли запускать тест. Это определяется реализацией интерфейса ExecutionCondition .

Давайте создадим класс EnvironmentExtension , который реализует этот интерфейс и переопределяет метод AssessmentExecutionCondition() .

Метод проверяет, равно ли свойство, представляющее имя текущей среды, «qa» , и отключает тест в этом случае:

public class EnvironmentExtension implements ExecutionCondition {

@Override
public ConditionEvaluationResult evaluateExecutionCondition(
ExtensionContext context) {

Properties props = new Properties();
props.load(EnvironmentExtension.class
.getResourceAsStream("application.properties"));
String env = props.getProperty("env");
if ("qa".equalsIgnoreCase(env)) {
return ConditionEvaluationResult
.disabled("Test disabled on QA environment");
}

return ConditionEvaluationResult.enabled(
"Test enabled on QA environment");
}
}

В результате тесты, регистрирующие это расширение, не будут выполняться в среде «qa» .

Если мы не хотим, чтобы условие проверялось, мы можем деактивировать его, установив ключ конфигурации junit.conditions.deactivate на шаблон, соответствующий условию.

Этого можно добиться, запустив JVM со свойством -Djunit.conditions.deactivate=<pattern> или добавив параметр конфигурации в LauncherDiscoveryRequest :

public class TestLauncher {
public static void main(String[] args) {
LauncherDiscoveryRequest request
= LauncherDiscoveryRequestBuilder.request()
.selectors(selectClass("com.foreach.EmployeesTest"))
.configurationParameter(
"junit.conditions.deactivate",
"com.foreach.extensions.*")
.build();

TestPlan plan = LauncherFactory.create().discover(request);
Launcher launcher = LauncherFactory.create();
SummaryGeneratingListener summaryGeneratingListener
= new SummaryGeneratingListener();
launcher.execute(
request,
new TestExecutionListener[] { summaryGeneratingListener });

System.out.println(summaryGeneratingListener.getSummary());
}
}

4.3. Обратные вызовы жизненного цикла

Этот набор расширений связан с событиями в жизненном цикле теста и может быть определен путем реализации следующих интерфейсов:

  • BeforeAllCallback и AfterAllCallback — выполняются до и после выполнения всех тестовых методов.
  • BeforeEachCallBack и AfterEachCallback — выполняются до и после каждого метода тестирования .
  • BeforeTestExecutionCallback и AfterTestExecutionCallback — выполняются непосредственно перед и сразу после тестового метода.

Если тест также определяет методы своего жизненного цикла, порядок выполнения следующий:

  1. Перед всем обратным вызовом
  2. Преждевсего
  3. Перед каждым обратным вызовом
  4. Перед каждым
  5. Обратный вызов Перед ТестВыполнением
  6. Тест
  7. Обратный вызов после выполнения теста
  8. AfterEach
  9. AfterEachCallback
  10. После всего
  11. AfterAllCallback

В нашем примере давайте определим класс, который реализует некоторые из этих интерфейсов и управляет поведением теста, обращающегося к базе данных с помощью JDBC.

Во-первых, давайте создадим простую сущность Employee :

public class Employee {

private long id;
private String firstName;
// constructors, getters, setters
}

Нам также понадобится служебный класс, который создает соединение на основе файла .properties :

public class JdbcConnectionUtil {

private static Connection con;

public static Connection getConnection()
throws IOException, ClassNotFoundException, SQLException{
if (con == null) {
// create connection
return con;
}
return con;
}
}

Наконец, давайте добавим простой DAO на основе JDBC , который манипулирует записями сотрудников :

public class EmployeeJdbcDao {
private Connection con;

public EmployeeJdbcDao(Connection con) {
this.con = con;
}

public void createTable() throws SQLException {
// create employees table
}

public void add(Employee emp) throws SQLException {
// add employee record
}

public List<Employee> findAll() throws SQLException {
// query all employee records
}
}

Давайте создадим наше расширение, которое реализует некоторые интерфейсы жизненного цикла:

public class EmployeeDatabaseSetupExtension implements 
BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
//...
}

Каждый из этих интерфейсов содержит метод, который нам нужно переопределить.

Для интерфейса BeforeAllCallback мы переопределим метод beforeAll() и добавим логику для создания таблицы сотрудников перед выполнением любого тестового метода:

private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();

@Override
public void beforeAll(ExtensionContext context) throws SQLException {
employeeDao.createTable();
}

Далее мы будем использовать BeforeEachCallback и AfterEachCallback , чтобы обернуть каждый тестовый метод в транзакцию. Целью этого является откат любых изменений в базе данных, выполненных в тестовом методе, чтобы следующий тест выполнялся на чистой базе данных.

В методе beforeEach() мы создадим точку сохранения, используемую для отката состояния базы данных до:

private Connection con = JdbcConnectionUtil.getConnection();
private Savepoint savepoint;

@Override
public void beforeEach(ExtensionContext context) throws SQLException {
con.setAutoCommit(false);
savepoint = con.setSavepoint("before");
}

Затем в методе afterEach() мы откатываем изменения базы данных, сделанные во время выполнения тестового метода:

@Override
public void afterEach(ExtensionContext context) throws SQLException {
con.rollback(savepoint);
}

Чтобы закрыть соединение, воспользуемся методом afterAll() , выполняемым после завершения всех тестов:

@Override
public void afterAll(ExtensionContext context) throws SQLException {
if (con != null) {
con.close();
}
}

4.4. Параметр Разрешение

Если конструктор теста или метод получает параметр, это должно быть разрешено во время выполнения с помощью ParameterResolver .

Давайте определим наш собственный ParameterResolver , который разрешает параметры типа EmployeeJdbcDao :

public class EmployeeDaoParameterResolver implements ParameterResolver {

@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) throws ParameterResolutionException {
return parameterContext.getParameter().getType()
.equals(EmployeeJdbcDao.class);
}

@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) throws ParameterResolutionException {
return new EmployeeJdbcDao();
}
}

Наш преобразователь реализует интерфейс ParameterResolver и переопределяет методы supportsParameter() и resolveParameter() . Первый из них проверяет тип параметра, а второй определяет логику для получения экземпляра параметра.

4.5. Обработка исключений

И последнее, но не менее важное: интерфейс TestExecutionExceptionHandler можно использовать для определения поведения теста при обнаружении определенных типов исключений.

Например, мы можем создать расширение, которое будет регистрировать и игнорировать все исключения типа FileNotFoundException при повторном создании любого другого типа:

public class IgnoreFileNotFoundExceptionExtension 
implements TestExecutionExceptionHandler {

Logger logger = LogManager
.getLogger(IgnoreFileNotFoundExceptionExtension.class);

@Override
public void handleTestExecutionException(ExtensionContext context,
Throwable throwable) throws Throwable {

if (throwable instanceof FileNotFoundException) {
logger.error("File not found:" + throwable.getMessage());
return;
}
throw throwable;
}
}

5. Регистрация расширений

Теперь, когда мы определили наши тестовые расширения, нам нужно зарегистрировать их с помощью теста JUnit 5. Для этого мы можем использовать аннотацию @ExtendWith .

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

@ExtendWith({ EnvironmentExtension.class, 
EmployeeDatabaseSetupExtension.class, EmployeeDaoParameterResolver.class })
@ExtendWith(LoggingExtension.class)
@ExtendWith(IgnoreFileNotFoundExceptionExtension.class)
public class EmployeesTest {
private EmployeeJdbcDao employeeDao;
private Logger logger;

public EmployeesTest(EmployeeJdbcDao employeeDao) {
this.employeeDao = employeeDao;
}

@Test
public void whenAddEmployee_thenGetEmployee() throws SQLException {
Employee emp = new Employee(1, "john");
employeeDao.add(emp);
assertEquals(1, employeeDao.findAll().size());
}

@Test
public void whenGetEmployees_thenEmptyList() throws SQLException {
assertEquals(0, employeeDao.findAll().size());
}

public void setLogger(Logger logger) {
this.logger = logger;
}
}

Мы видим, что в нашем тестовом классе есть конструктор с параметром EmployeeJdbcDao , который будет разрешен путем расширения расширения EmployeeDaoParameterResolver .

При добавлении EnvironmentExtension наш тест будет выполняться только в среде, отличной от «qa» .

В нашем тесте также будет создана таблица сотрудников , и каждый метод будет заключен в транзакцию путем добавления EmployeeDatabaseSetupExtension . Даже если сначала выполняется тест whenAddEmployee_thenGetEmploee() , добавляющий в таблицу одну запись, второй тест найдет в таблице 0 записей.

Экземпляр регистратора будет добавлен в наш класс с помощью LoggingExtension .

Наконец, наш тестовый класс будет игнорировать все экземпляры FileNotFoundException , поскольку он добавляет соответствующее расширение.

5.1. Автоматическая регистрация расширения

Если мы хотим зарегистрировать расширение для всех тестов в нашем приложении, мы можем сделать это, добавив полное имя в файл /META-INF/services/org.junit.jupiter.api.extension.Extension :

com.foreach.extensions.LoggingExtension

Чтобы этот механизм был включен, нам также необходимо установить для конфигурационного ключа junit.jupiter.extensions.autodetection.enabled значение true. Это можно сделать, запустив JVM со свойством — Djunit.jupiter.extensions.autodetection.enabled=true , или добавив параметр конфигурации в LauncherDiscoveryRequest :

LauncherDiscoveryRequest request
= LauncherDiscoveryRequestBuilder.request()
.selectors(selectClass("com.foreach.EmployeesTest"))
.configurationParameter("junit.jupiter.extensions.autodetection.enabled", "true")
.build();

5.2. Программная регистрация расширения

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

В дополнение к декларативному подходу, основанному на аннотациях, JUnit предоставляет API для программной регистрации расширений . Например, мы можем модифицировать класс JdbcConnectionUtil , чтобы он принимал свойства соединения:

public class JdbcConnectionUtil {

private static Connection con;

// no-arg getConnection

public static Connection getConnection(String url, String driver, String username, String password) {
if (con == null) {
// create connection
return con;
}

return con;
}
}

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

public EmployeeDatabaseSetupExtension(String url, String driver, String username, String password) {
con = JdbcConnectionUtil.getConnection(url, driver, username, password);
employeeDao = new EmployeeJdbcDao(con);
}

Теперь, чтобы зарегистрировать расширение сотрудника с пользовательскими свойствами базы данных, мы должны аннотировать статическое поле аннотацией @ RegisterExtension :

@ExtendWith({EnvironmentExtension.class, EmployeeDaoParameterResolver.class})
public class ProgrammaticEmployeesUnitTest {

private EmployeeJdbcDao employeeDao;

@RegisterExtension
static EmployeeDatabaseSetupExtension DB =
new EmployeeDatabaseSetupExtension("jdbc:h2:mem:AnotherDb;DB_CLOSE_DELAY=-1", "org.h2.Driver", "sa", "");

// same constrcutor and tests as before
}

Здесь мы подключаемся к базе данных H2 в памяти для запуска тестов.

5.3. Регистрация Заказ

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

Если мы зарегистрируем несколько расширений программно, через @RegisterExtension , JUnit зарегистрирует эти расширения в детерминированном порядке. Хотя упорядочение является детерминированным, алгоритм, используемый для упорядочения, является неочевидным и внутренним. Чтобы обеспечить определенный порядок регистрации, мы можем использовать аннотацию @Order :

public class MultipleExtensionsUnitTest {

@Order(1)
@RegisterExtension
static EmployeeDatabaseSetupExtension SECOND_DB = // omitted

@Order(0)
@RegisterExtension
static EmployeeDatabaseSetupExtension FIRST_DB = // omitted

@RegisterExtension
static EmployeeDatabaseSetupExtension LAST_DB = // omitted

// omitted
}

Здесь расширения упорядочены на основе приоритета, где более низкое значение имеет более высокий приоритет, чем более высокое значение . Кроме того, расширения без аннотации @Order будут иметь самый низкий возможный приоритет.

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

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

Полный исходный код примеров можно найти на GitHub .