Guide des extensions JUnit 5

Guide des extensions JUnit 5

1. Vue d'ensemble

Dans cet article, nous allons examiner le modèle d'extension dans la bibliothèque de test JUnit 5. Comme son nom l'indique,the purpose of Junit 5 extensions is to extend the behavior of test classes or methods, et ceux-ci peuvent être réutilisés pour plusieurs tests.

Avant Junit 5, la version JUnit 4 de la bibliothèque utilisait deux types de composants pour étendre un test: les exécuteurs de tests et les règles. Par comparaison, JUnit 5 simplifie le mécanisme d'extension en introduisant un seul concept: l'APIExtension.

2. Modèle d'extension JUnit 5

Les extensions JUnit 5 sont liées à un certain événement dans l'exécution d'un test, appelé point d'extension. Lorsqu'une certaine phase du cycle de vie est atteinte, le moteur JUnit appelle les extensions enregistrées.

Cinq types principaux de points d’extension peuvent être utilisés:

  • post-traitement d'instance de test

  • exécution conditionnelle du test

  • rappels de cycle de vie

  • résolution de paramètre

  • gestion des exceptions

Nous passerons en revue chacun de ces éléments plus en détail dans les sections suivantes.

3. Dépendances Maven

Tout d'abord, ajoutons les dépendances de projet dont nous aurons besoin pour nos exemples. La principale bibliothèque JUnit 5 dont nous aurons besoin estjunit-jupiter-engine:


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

Ajoutons également deux bibliothèques d'aide à utiliser pour nos exemples:


    org.apache.logging.log4j
    log4j-core
    2.8.2


    com.h2database
    h2
    1.4.196

Les dernières versions dejunit-jupiter-engine,h2 etlog4j-core peuvent être téléchargées depuis Maven Central.

4. Création d'extensions JUnit 5

Pour créer une extension JUnit 5, nous devons définir une classe qui implémente une ou plusieurs interfaces correspondant aux points d'extension JUnit 5. Toutes ces interfaces étendent l'interface principaleExtension, qui n'est qu'une interface de marqueur.

4.1. ExtensionTestInstancePostProcessor

Ce type d'extension est exécuté après la création d'une instance de test. L'interface à implémenter estTestInstancePostProcessor qui a une méthodepostProcessTestInstance() à remplacer.

Un cas d'utilisation typique de cette extension est l'injection de dépendances dans l'instance. Par exemple, créons une extension qui instancie un objetlogger, puis appelle la méthodesetLogger() sur l'instance de test:

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);
    }
}

Comme on peut le voir ci-dessus, la méthodepostProcessTestInstance() donne accès à l'instance de test et appelle la méthodesetLogger() de la classe de test en utilisant le mécanisme de réflexion.

4.2. Exécution de test conditionnel

JUnit 5 fournit un type d'extension qui peut contrôler si un test doit être exécuté ou non. Ceci est défini en implémentant l'interfaceExecutionCondition.

Créons une classeEnvironmentExtension qui implémente cette interface et remplace la méthodeevaluateExecutionCondition().

La méthode vérifie si une propriété représentant le nom de l'environnement actuel est égale à“qa” et désactive le test dans ce cas:

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");
    }
}

Par conséquent, les tests qui enregistrent cette extension ne seront pas exécutés sur l'environnement“qa”.

If we do not want a condition to be validated, we can deactivate it by setting the junit.conditions.deactivate configuration key à un modèle qui correspond à la condition.

Cela peut être réalisé en démarrant la JVM avec la propriété-Djunit.conditions.deactivate=<pattern>, ou en ajoutant un paramètre de configuration auxLauncherDiscoveryRequest:

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. Rappels du cycle de vie

Cet ensemble d'extensions est lié aux événements du cycle de vie d'un test et peut être défini en implémentant les interfaces suivantes:

  • BeforeAllCallback etAfterAllCallback - exécutés avant et après l'exécution de toutes les méthodes de test

  • BeforeEachCallBack etAfterEachCallback - exécutés avant et après chaque méthode de test

  • BeforeTestExecutionCallback etAfterTestExecutionCallback - exécutés immédiatement avant et immédiatement après une méthode de test

Si le test définit également ses méthodes de cycle de vie, l'ordre d'exécution est le suivant:

  1. BeforeAllCallback

  2. Avant tout

  3. BeforeEachCallback

  4. Avant chaque

  5. BeforeTestExecutionCallback

  6. Test

  7. AfterTestExecutionCallback

  8. Après chaque

  9. AfterEachCallback

  10. Après tout

  11. AfterAllCallback

Pour notre exemple, définissons une classe qui implémente certaines de ces interfaces et contrôle le comportement d'un test qui accède à une base de données à l'aide de JDBC.

Commençons par créer une simple entitéEmployee:

public class Employee {

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

Nous aurons également besoin d'une classe utilitaire qui crée unConnection basé sur un fichier.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;
    }
}

Enfin, ajoutons un simpleDAO basé sur JDBC qui manipule les enregistrementsEmployee:

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
    }
}

Créons notre extension qui implémente certaines des interfaces du cycle de vie:

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

Chacune de ces interfaces contient une méthode à redéfinir.

Pour l'interfaceBeforeAllCallback, nous remplacerons la méthodebeforeAll() et ajouterons la logique pour créer notre tableemployees avant l'exécution de toute méthode de test:

private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();

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

Ensuite, nous utiliserons lesBeforeEachCallback etAfterEachCallback pour envelopper chaque méthode de test dans une transaction. L'objectif est d'annuler toutes les modifications apportées à la base de données exécutée dans la méthode de test afin que le prochain test s'exécute sur une base de données vierge.

Dans la méthodebeforeEach(), nous allons créer un point de sauvegarde à utiliser pour restaurer l'état de la base de données pour:

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

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

Ensuite, dans la méthodeafterEach(), nous annulerons les modifications apportées à la base de données lors de l'exécution d'une méthode de test:

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

Pour fermer la connexion, nous utiliserons la méthodeafterAll(), exécutée une fois tous les tests terminés:

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

4.4. Résolution des paramètres

Si un constructeur ou une méthode de test reçoit un paramètre, cela doit être résolu au moment de l'exécution par unParameterResolver.

Définissons nos propresParameterResolver personnalisés qui résolvent les paramètres de typeEmployeeJdbcDao:

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();
    }
}

Notre résolveur implémente l'interfaceParameterResolver et remplace les méthodessupportsParameter() etresolveParameter(). Le premier vérifie le type du paramètre, tandis que le second définit la logique pour obtenir une instance de paramètre.

4.5. Gestion des exceptions

Enfin, l'interfaceTestExecutionExceptionHandler peut être utilisée pour définir le comportement d'un test lors de la rencontre de certains types d'exceptions.

Par exemple, nous pouvons créer une extension qui enregistrera et ignorera toutes les exceptions de typeFileNotFoundException, tout en renvoyant tout autre type:

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. Enregistrement d'extensions

Maintenant que nous avons défini nos extensions de test, nous devons les enregistrer avec un test JUnit 5. Pour y parvenir, nous pouvons utiliser l'annotation@ExtendWith.

L'annotation peut être ajoutée plusieurs fois à un test ou recevoir une liste d'extensions en tant que paramètre:

@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;
    }
}

Nous pouvons voir que notre classe de test a un constructeur avec un paramètreEmployeeJdbcDao qui sera résolu en étendant l'extensionEmployeeDaoParameterResolver.

En ajoutant lesEnvironmentExtension, notre test ne sera exécuté que dans un environnement différent de“qa”.

Notre test aura également la tableemployees créée et chaque méthode encapsulée dans une transaction en ajoutant lesEmployeeDatabaseSetupExtension. Même si le testwhenAddEmployee_thenGetEmploee() est exécuté en premier, ce qui ajoute un enregistrement à la table, le second test trouvera 0 enregistrement dans la table.

Une instance de journalisation sera ajoutée à notre classe en utilisant lesLoggingExtension.

Enfin, notre classe de test ignorera toutes les instances deFileNotFoundException, car elle ajoute l'extension correspondante.

5.1. Enregistrement automatique des extensions

Si nous voulons enregistrer une extension pour tous les tests de notre application, nous pouvons le faire en ajoutant le nom complet au fichier/META-INF/services/org.junit.jupiter.api.extension.Extension:

com.example.extensions.LoggingExtension

Pour que ce mécanisme soit activé, nous devons également définir la clé de configurationjunit.jupiter.extensions.autodetection.enabled sur true. Cela peut être fait en démarrant la JVM avec la propriété -Djunit.jupiter.extensions.autodetection.enabled=true, ou en ajoutant un paramètre de configuration àLauncherDiscoveryRequest:

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

5.2. Enregistrement d'extension programmatique

Bien que l'enregistrement d'extensions à l'aide d'annotations soit une approche plus déclarative et discrète, elle présente un inconvénient majeur:we can’t easily customize the extension behavior. Par exemple, avec le modèle d'enregistrement d'extensions actuel, nous ne pouvons pas accepter les propriétés de connexion à la base de données du client.

En plus de l'approche déclarative basée sur les annotations, JUnit fournit une API pour enregistrer les extensionsprogrammatically.  Par exemple, nous pouvons moderniser la classeJdbcConnectionUtil pour accepter les propriétés de connexion:

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;
    }
}

De plus, nous devrions ajouter un nouveau constructeur pour la sextensionEmployeeDatabaseSetupExtension pour prendre en charge les propriétés de base de données personnalisées:

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

Maintenant, pour enregistrer l'extension d'employé avec des propriétés de base de données personnalisées, nous devons annoter un champ statique avec l'annotation @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
}

Ici, nous nous connectons à une base de données H2 en mémoire pour exécuter les tests.

5.3. Commande d'inscription

JUnit registers @RegisterExtension static fields after registering extensions that are declaratively defined using the @ExtendsWith annotation. Nous pouvons également utiliser des champs non statiques pour l'enregistrement programmatique, mais ils seront enregistrés après l'instanciation de la méthode de test et les post-processeurs.

Si nous enregistrons plusieurs extensions par programmation, via@RegisterExtension, JUnit enregistrera ces extensions dans un ordre déterministe. Bien que l'ordre soit déterministe, l'algorithme utilisé pour cet ordre est non évident et interne. À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
}

Ici, les extensions sontordered based on priority, where a lower value has greater priority than a higher value. De plus, les extensions sans annotation@Order auraient la priorité la plus basse possible.

6. Conclusion

Dans ce tutoriel, nous avons montré comment utiliser le modèle d'extension JUnit 5 pour créer des extensions de test personnalisées.

Le code source complet des exemples peut être trouvéover on GitHub.