Um guia para extensões JUnit 5
1. Visão geral
Neste artigo, vamos dar uma olhada no modelo de extensão na biblioteca de testes JUnit 5. Como o nome sugere,the purpose of Junit 5 extensions is to extend the behavior of test classes or methods, e estes podem ser reutilizados para vários testes.
Antes de 5 de junho, a versão JUnit 4 da biblioteca usava dois tipos de componentes para estender um teste: executores de teste e regras. Em comparação, o JUnit 5 simplifica o mecanismo de extensão introduzindo um único conceito: a APIExtension.
2. Modelo de extensão JUnit 5
As extensões do JUnit 5 estão relacionadas a um determinado evento na execução de um teste, conhecido como ponto de extensão. Quando uma determinada fase do ciclo de vida é atingida, o mecanismo JUnit chama ramais registrados.
Podem ser utilizados cinco tipos principais de pontos de extensão:
-
pós-processamento da instância de teste
-
execução de teste condicional
-
retornos de chamada do ciclo de vida
-
resolução de parâmetros
-
manipulação de exceção
Examinaremos cada um deles com mais detalhes nas seções a seguir.
3. Dependências do Maven
Primeiro, vamos adicionar as dependências do projeto que precisaremos para nossos exemplos. A principal biblioteca JUnit 5 de que precisaremos éjunit-jupiter-engine:
org.junit.jupiter
junit-jupiter-engine
5.4.2
test
Além disso, vamos adicionar duas bibliotecas auxiliares para usar em nossos exemplos:
org.apache.logging.log4j
log4j-core
2.8.2
com.h2database
h2
1.4.196
As versões mais recentes dejunit-jupiter-engine,h2elog4j-core podem ser baixadas do Maven Central.
4. Criação de extensões JUnit 5
Para criar uma extensão JUnit 5, precisamos definir uma classe que implemente uma ou mais interfaces correspondentes aos pontos de extensão JUnit 5. Todas essas interfaces estendem a interface principalExtension, que é apenas uma interface de marcador.
4.1. ExtensãoTestInstancePostProcessor
Esse tipo de extensão é executado após a criação de uma instância de um teste. A interface a ser implementada éTestInstancePostProcessor, que possui um métodopostProcessTestInstance() para substituir.
Um caso de uso típico para esta extensão é injetar dependências na instância. Por exemplo, vamos criar uma extensão que instancia um objetologger e, em seguida, chame o métodosetLogger() na instância de teste:
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);
}
}
Como pode ser visto acima, o métodopostProcessTestInstance() fornece acesso à instância de teste e chama o métodosetLogger() da classe de teste usando o mecanismo de reflexão.
4.2. Execução de teste condicional
O JUnit 5 fornece um tipo de extensão que pode controlar se um teste deve ou não ser executado. Isso é definido pela implementação da interfaceExecutionCondition.
Vamos criar uma classeEnvironmentExtension que implementa esta interface e substitui o métodoevaluateExecutionCondition().
O método verifica se uma propriedade que representa o nome do ambiente atual é igual a“qa”e desativa o teste neste caso:
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");
}
}
Como resultado, os testes que registram essa extensão não serão executados no ambiente“qa”.
If we do not want a condition to be validated, we can deactivate it by setting the junit.conditions.deactivate configuration key para um padrão que corresponda à condição.
Isso pode ser alcançado iniciando a JVM com a propriedade-Djunit.conditions.deactivate=<pattern> ou adicionando um parâmetro de configuração aoLauncherDiscoveryRequest:
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. Retornos de chamada do ciclo de vida
Este conjunto de extensões está relacionado a eventos no ciclo de vida de um teste e pode ser definido implementando as seguintes interfaces:
-
BeforeAllCallbackeAfterAllCallback - executado antes e depois de todos os métodos de teste serem executados
-
BeforeEachCallBackeAfterEachCallback - executado antes e depois de cada método de teste
-
BeforeTestExecutionCallbackeAfterTestExecutionCallback - executado imediatamente antes e imediatamente após um método de teste
Se o teste também definir seus métodos de ciclo de vida, a ordem de execução será:
-
BeforeAllCallback
-
Antes de tudo
-
BeforeEachCallback
-
BeforeEach
-
BeforeTestExecutionCallback
-
Test
-
AfterTestExecutionCallback
-
Após cada
-
AfterEachCallback
-
Depois de tudo
-
AfterAllCallback
Para nosso exemplo, vamos definir uma classe que implementa algumas dessas interfaces e controla o comportamento de um teste que acessa um banco de dados usando JDBC.
Primeiro, vamos criar uma entidadeEmployee simples:
public class Employee {
private long id;
private String firstName;
// constructors, getters, setters
}
Também precisaremos de uma classe de utilitário que crie umConnection com base em um arquivo.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;
}
}
Finalmente, vamos adicionar umDAO baseado em JDBC simples que manipula os registrosEmployee:
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
}
}
Vamos criar nossa extensão que implementa algumas das interfaces do ciclo de vida:
public class EmployeeDatabaseSetupExtension implements
BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
//...
}
Cada uma dessas interfaces contém um método que precisamos substituir.
Para a interfaceBeforeAllCallback, substituiremos o métodobeforeAll()e adicionaremos a lógica para criar nossa tabelaemployees antes que qualquer método de teste seja executado:
private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();
@Override
public void beforeAll(ExtensionContext context) throws SQLException {
employeeDao.createTable();
}
A seguir, usaremosBeforeEachCallbackeAfterEachCallback para envolver cada método de teste em uma transação. O objetivo disso é reverter quaisquer alterações no banco de dados executadas no método de teste, para que o próximo teste seja executado em um banco de dados limpo.
No métodobeforeEach(), criaremos um ponto de salvamento a ser usado para reverter o estado do banco de dados para:
private Connection con = JdbcConnectionUtil.getConnection();
private Savepoint savepoint;
@Override
public void beforeEach(ExtensionContext context) throws SQLException {
con.setAutoCommit(false);
savepoint = con.setSavepoint("before");
}
Então, no métodoafterEach(), reverteremos as alterações do banco de dados feitas durante a execução de um método de teste:
@Override
public void afterEach(ExtensionContext context) throws SQLException {
con.rollback(savepoint);
}
Para fechar a conexão, faremos uso do métodoafterAll(), executado após a conclusão de todos os testes:
@Override
public void afterAll(ExtensionContext context) throws SQLException {
if (con != null) {
con.close();
}
}
4.4. Resolução de Parâmetro
Se um construtor ou método de teste receber um parâmetro, isso deve ser resolvido em tempo de execução porParameterResolver.
Vamos definir nosso próprioParameterResolver personalizado que resolve parâmetros do tipoEmployeeJdbcDao:
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();
}
}
Nosso resolvedor implementa a interfaceParameterResolvere substitui os métodossupportsParameter()eresolveParameter(). O primeiro deles verifica o tipo do parâmetro, enquanto o segundo define a lógica para obter uma instância do parâmetro.
4.5. Manipulação de exceção
Por último, mas não menos importante, a interfaceTestExecutionExceptionHandler pode ser usada para definir o comportamento de um teste ao encontrar certos tipos de exceções.
Por exemplo, podemos criar uma extensão que irá registrar e ignorar todas as exceções do tipoFileNotFoundException, enquanto relança qualquer outro tipo:
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. Registrando extensões
Agora que definimos nossas extensões de teste, precisamos registrá-las com um teste JUnit 5. Para isso, podemos fazer uso da anotação@ExtendWith.
A anotação pode ser adicionada várias vezes a um teste ou receber uma lista de extensões como parâmetro:
@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;
}
}
Podemos ver que nossa classe de teste tem um construtor com um parâmetroEmployeeJdbcDao que será resolvido estendendo a extensãoEmployeeDaoParameterResolver.
Ao adicionarEnvironmentExtension, nosso teste só será executado em um ambiente diferente de“qa”.
Nosso teste também terá a tabelaemployees criada e cada método envolvido em uma transação adicionandoEmployeeDatabaseSetupExtension. Mesmo se o testewhenAddEmployee_thenGetEmploee() for executado primeiro, o que adiciona um registro à tabela, o segundo teste encontrará 0 registros na tabela.
Uma instância do logger será adicionada à nossa classe usandoLoggingExtension.
Finalmente, nossa classe de teste irá ignorar todas as instâncias deFileNotFoundException, uma vez que está adicionando a extensão correspondente.
5.1. Registro automático de ramais
Se quisermos registrar uma extensão para todos os testes em nosso aplicativo, podemos fazer isso adicionando o nome totalmente qualificado ao arquivo/META-INF/services/org.junit.jupiter.api.extension.Extension:
com.example.extensions.LoggingExtension
Para que esse mecanismo seja habilitado, também precisamos definir a chave de configuraçãojunit.jupiter.extensions.autodetection.enabled como verdadeira. Isso pode ser feito iniciando a JVM com a propriedade -Djunit.jupiter.extensions.autodetection.enabled=true, ou adicionando um parâmetro de configuração aLauncherDiscoveryRequest:
LauncherDiscoveryRequest request
= LauncherDiscoveryRequestBuilder.request()
.selectors(selectClass("com.example.EmployeesTest"))
.configurationParameter("junit.jupiter.extensions.autodetection.enabled", "true")
.build();
5.2. Registro de extensão programática
Embora registrar extensões usando anotações seja uma abordagem mais declarativa e discreta, tem uma desvantagem significativa:we can’t easily customize the extension behavior. Por exemplo, com o modelo de registro de extensão atual, não podemos aceitar as propriedades de conexão de banco de dados do cliente.
Além da abordagem baseada em anotação declarativa, JUnit fornece uma API para registrar extensõesprogrammatically. . Por exemplo, podemos adaptar o sclassJdbcConnectionUtil para aceitar as propriedades de conexão:
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;
}
}
Além disso, devemos adicionar um novo construtor para a sextensãoEmployeeDatabaseSetupExtension para oferecer suporte às propriedades personalizadas do banco de dados:
public EmployeeDatabaseSetupExtension(String url, String driver, String username, String password) {
con = JdbcConnectionUtil.getConnection(url, driver, username, password);
employeeDao = new EmployeeJdbcDao(con);
}
Agora, para registrar a extensão do funcionário com propriedades personalizadas do banco de dados, devemos anotar um campo estático com a anotação @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
}
Aqui, estamos nos conectando a um banco de dados H2 em memória para executar os testes.
5.3. Pedido de registro
JUnit registers @RegisterExtension static fields after registering extensions that are declaratively defined using the @ExtendsWith annotation. Também podemos usar campos não estáticos para registro programático, mas eles serão registrados após a instanciação do método de teste e pós-processadores.
Se registrarmos várias extensões programaticamente, por meio de@RegisterExtension, o JUnit registrará essas extensões em uma ordem determinística. Embora a ordem seja determinística, o algoritmo usado para a ordem é não óbvio e interno. Paraenforce 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
}
Aqui, as extensões sãoordered based on priority, where a lower value has greater priority than a higher value. Além disso, extensões sem@Order annotation teriam a prioridade mais baixa possível.
6. Conclusão
Neste tutorial, mostramos como podemos usar o modelo de extensão JUnit 5 para criar extensões de teste personalizadas.
O código-fonte completo dos exemplos pode ser encontradoover on GitHub.