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
— выполняются непосредственно перед и сразу после тестового метода.
Если тест также определяет методы своего жизненного цикла, порядок выполнения следующий:
Перед всем обратным вызовом
Преждевсего
Перед каждым обратным вызовом
Перед каждым
Обратный вызов Перед ТестВыполнением
Тест
Обратный вызов после выполнения теста
AfterEach
AfterEachCallback
- После всего
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 .