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

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

1. обзор

В этой статье мы рассмотрим модель расширения в тестовой библиотеке JUnit 5. Как следует из названия,the purpose of Junit 5 extensions is to extend the behavior of test classes or methods, и их можно повторно использовать для нескольких тестов.

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

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

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

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

  • постобработка тестового экземпляра

  • выполнение условного теста

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

  • разрешение параметров

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

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

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

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


    org.junit.jupiter
    junit-jupiter-engine
    5.4.2
    test

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


    org.apache.logging.log4j
    log4j-core
    2.8.2


    com.h2database
    h2
    1.4.196

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

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

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

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

Расширение этого типа выполняется после того, как был создан экземпляр теста. Реализуемый интерфейс -TestInstancePostProcessor, у которого есть методpostProcessTestInstance(), который нужно переопределить.

Типичным вариантом использования этого расширения является внедрение зависимостей в экземпляр. Например, давайте создадим расширение, которое создает экземпляр объектаlogger, а затем вызывает метод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, который реализует этот интерфейс и переопределяет методevaluateExecutionCondition().

Метод проверяет, равно ли свойство, представляющее текущее имя среды,“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”.

If we do not want a condition to be validated, we can deactivate it by setting the junit.conditions.deactivate configuration key в шаблон, соответствующий условию.

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

public class TestLauncher {
    public static void main(String[] args) {
        LauncherDiscoveryRequest request
          = LauncherDiscoveryRequestBuilder.request()
          .selectors(selectClass("com.example.EmployeesTest"))
          .configurationParameter(
            "junit.conditions.deactivate",
            "com.example.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. BeforeAllCallback

  2. BeforeAll

  3. BeforeEachCallback

  4. Перед каждым

  5. BeforeTestExecutionCallback

  6. Test

  7. AfterTestExecutionCallback

  8. AfterEach

  9. AfterEachCallback

  10. В конце концов

  11. AfterAllCallback

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

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

public class Employee {

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

Нам также понадобится служебный класс, который создаетConnection на основе файла.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, который управляет записямиEmployee:

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 findAll() throws SQLException {
       // query all employee records
    }
}

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

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

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

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

private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();

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

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

В методе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”.

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

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

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

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

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

com.example.extensions.LoggingExtension

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

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

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

Хотя регистрация расширений с использованием аннотаций является более декларативным и ненавязчивым подходом, у нее есть существенный недостаток:we can’t easily customize the extension behavior. Например, с текущей моделью регистрации расширений мы не можем принимать свойства подключения к базе данных от клиента.

В дополнение к подходу, основанному на декларативных аннотациях, JUnit предоставляет API для регистрации расширенийprogrammatically. . Например, мы можем модифицировать класс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 registers @RegisterExtension static fields after registering extensions that are declaratively defined using the @ExtendsWith annotation. Мы также можем использовать нестатические поля для программной регистрации, но они будут зарегистрированы после создания экземпляра метода тестирования и постпроцессоров.

Если мы программно зарегистрируем несколько расширений через@RegisterExtension, JUnit зарегистрирует эти расширения в детерминированном порядке. Хотя упорядочение является детерминированным, алгоритм, используемый для упорядочения, неочевиден и является внутренним. Кenforce a particular registration ordering, we can use the @Order annotation:

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
}

Здесь расширения -ordered based on priority, where a lower value has greater priority than a higher value. Кроме того, расширения без саннотации@Order будут иметь самый низкий возможный приоритет.

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

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

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