Ein Handbuch zu den Erweiterungen von JUnit 5

Eine Anleitung zu JUnit 5-Erweiterungen

1. Überblick

In diesem Artikel werfen wir einen Blick auf das Erweiterungsmodell in der JUnit 5-Testbibliothek. Wie der Name schon sagt,the purpose of Junit 5 extensions is to extend the behavior of test classes or methods, und diese können für mehrere Tests wiederverwendet werden.

Vor Junit 5 wurden in der JUnit 4-Version der Bibliothek zwei Arten von Komponenten zum Erweitern eines Tests verwendet: Testläufer und Regeln. Im Vergleich dazu vereinfacht JUnit 5 den Erweiterungsmechanismus durch die Einführung eines einzigen Konzepts: derExtension-API.

2. JUnit 5-Erweiterungsmodell

JUnit 5-Erweiterungen beziehen sich auf ein bestimmtes Ereignis bei der Ausführung eines Tests, das als Erweiterungspunkt bezeichnet wird. Wenn eine bestimmte Lebenszyklusphase erreicht ist, ruft die JUnit-Engine registrierte Erweiterungen auf.

Fünf Haupttypen von Erweiterungspunkten können verwendet werden:

  • Nachbearbeitung der Testinstanz

  • bedingte Testausführung

  • Lebenszyklus-Rückrufe

  • Parameterauflösung

  • Ausnahmebehandlung

Wir werden diese in den folgenden Abschnitten ausführlicher behandeln.

3. Maven-Abhängigkeiten

Fügen wir zunächst die Projektabhängigkeiten hinzu, die wir für unsere Beispiele benötigen. Die Hauptbibliothek von JUnit 5, die wir benötigen, istjunit-jupiter-engine:


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

Fügen wir außerdem zwei Hilfsbibliotheken hinzu, die für unsere Beispiele verwendet werden sollen:


    org.apache.logging.log4j
    log4j-core
    2.8.2


    com.h2database
    h2
    1.4.196

Die neuesten Versionen vonjunit-jupiter-engine,h2 undlog4j-core können von Maven Central heruntergeladen werden.

4. Erstellen von JUnit 5-Erweiterungen

Um eine JUnit 5-Erweiterung zu erstellen, müssen Sie eine Klasse definieren, die eine oder mehrere Schnittstellen implementiert, die den JUnit 5-Erweiterungspunkten entsprechen. Alle diese Schnittstellen erweitern die Hauptschnittstelle vonExtension, die nur eine Markierungsschnittstelle ist.

4.1. TestInstancePostProcessor Erweiterung

Diese Art der Erweiterung wird ausgeführt, nachdem eine Testinstanz erstellt wurde. Die zu implementierende Schnittstelle istTestInstancePostProcessor, die einepostProcessTestInstance()-Methode zum Überschreiben hat.

Ein typischer Anwendungsfall für diese Erweiterung ist das Einfügen von Abhängigkeiten in die Instanz. Erstellen Sie beispielsweise eine Erweiterung, die das Objektloggerinstanziiert und dann die MethodesetLogger()für die Testinstanz aufruft:

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

Wie oben zu sehen ist, bietet die MethodepostProcessTestInstance() Zugriff auf die Testinstanz und ruft die MethodesetLogger()der Testklasse unter Verwendung des Reflexionsmechanismus auf.

4.2. Bedingte Testausführung

JUnit 5 bietet eine Art Erweiterung, mit der gesteuert werden kann, ob ein Test ausgeführt werden soll oder nicht. Dies wird durch Implementieren derExecutionCondition-Schnittstelle definiert.

Erstellen wir eineEnvironmentExtension-Klasse, die diese Schnittstelle implementiert und dieevaluateExecutionCondition()-Methode überschreibt.

Die Methode überprüft, ob eine Eigenschaft, die den aktuellen Umgebungsnamen darstellt,“qa” entspricht, und deaktiviert den Test in diesem Fall:

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

Daher werden Tests, die diese Erweiterung registrieren, nicht in der Umgebung von“qa”ausgeführt.

If we do not want a condition to be validated, we can deactivate it by setting the junit.conditions.deactivate configuration key auf ein Muster, das der Bedingung entspricht.

Dies kann erreicht werden, indem die JVM mit der Eigenschaft-Djunit.conditions.deactivate=<pattern> gestartet wird oder indemLauncherDiscoveryRequest ein Konfigurationsparameter hinzugefügt wird:

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. Lebenszyklus-Rückrufe

Diese Erweiterungen beziehen sich auf Ereignisse im Lebenszyklus eines Tests und können durch Implementierung der folgenden Schnittstellen definiert werden:

  • BeforeAllCallback undAfterAllCallback - werden ausgeführt, bevor und nachdem alle Testmethoden ausgeführt wurden

  • BeforeEachCallBack undAfterEachCallback - vor und nach jeder Testmethode ausgeführt

  • BeforeTestExecutionCallback undAfterTestExecutionCallback - unmittelbar vor und unmittelbar nach einer Testmethode ausgeführt

Wenn der Test auch seine Lebenszyklusmethoden definiert, lautet die Ausführungsreihenfolge:

  1. BeforeAllCallback

  2. Vor allen

  3. BeforeEachCallback

  4. Vor jedem

  5. BeforeTestExecutionCallback

  6. Test

  7. AfterTestExecutionCallback

  8. Nach jedem

  9. AfterEachCallback

  10. Nach alldem

  11. AfterAllCallback

In unserem Beispiel definieren wir eine Klasse, die einige dieser Schnittstellen implementiert und das Verhalten eines Tests steuert, der über JDBC auf eine Datenbank zugreift.

Erstellen wir zunächst eine einfacheEmployee-Entität:

public class Employee {

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

Wir benötigen außerdem eine Dienstprogrammklasse, die einConnection basierend auf einer.properties-Datei erstellt:

public class JdbcConnectionUtil {

    private static Connection con;

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

Fügen wir abschließend ein einfaches JDBC-basiertesDAOhinzu, das die Datensätze vonEmployeebearbeitet:

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

Erstellen wir unsere Erweiterung, die einige der Lebenszyklusschnittstellen implementiert:

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

Jede dieser Schnittstellen enthält eine Methode, die wir überschreiben müssen.

Für dieBeforeAllCallback-Schnittstelle überschreiben wir diebeforeAll()-Methode und fügen die Logik hinzu, um unsereemployees-Tabelle zu erstellen, bevor eine Testmethode ausgeführt wird:

private EmployeeJdbcDao employeeDao = new EmployeeJdbcDao();

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

Als nächstes werden wir dieBeforeEachCallback undAfterEachCallback verwenden, um jede Testmethode in eine Transaktion zu verpacken. Der Zweck ist, alle Änderungen an der Datenbank, die in der Testmethode ausgeführt wurden, zurückzusetzen, damit der nächste Test auf einer sauberen Datenbank ausgeführt wird.

In derbeforeEach()-Methode erstellen wir einen Speicherpunkt, um den Status der Datenbank auf Folgendes zurückzusetzen:

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

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

In derafterEach()-Methode werden dann die Datenbankänderungen zurückgesetzt, die während der Ausführung einer Testmethode vorgenommen wurden:

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

Um die Verbindung zu schließen, verwenden wir die MethodeafterAll(), die ausgeführt wird, nachdem alle Tests abgeschlossen wurden:

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

4.4. Parameterauflösung

Wenn ein Testkonstruktor oder eine Testmethode einen Parameter empfängt, muss dieser zur Laufzeit umParameterResolver aufgelöst werden.

Definieren wir unsere eigenen benutzerdefiniertenParameterResolver, die Parameter vom TypEmployeeJdbcDao auflösen:

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

Unser Resolver implementiert die SchnittstelleParameterResolverund überschreibt die MethodensupportsParameter() undresolveParameter(). Der erste überprüft den Typ des Parameters, während der zweite die Logik zum Abrufen einer Parameterinstanz definiert.

4.5. Ausnahmebehandlung

Last but not least kann dieTestExecutionExceptionHandler-Schnittstelle verwendet werden, um das Verhalten eines Tests zu definieren, wenn bestimmte Arten von Ausnahmen auftreten.

Zum Beispiel können wir eine Erweiterung erstellen, die alle Ausnahmen vom TypFileNotFoundException protokolliert und ignoriert, während jeder andere Typ erneut ausgelöst wird:

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. Erweiterungen registrieren

Nachdem wir unsere Testerweiterungen definiert haben, müssen wir sie bei einem JUnit 5-Test registrieren. Um dies zu erreichen, können wir die Annotation@ExtendWithverwenden.

Die Anmerkung kann einem Test mehrmals hinzugefügt werden oder eine Liste mit Erweiterungen als Parameter erhalten:

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

Wir können sehen, dass unsere Testklasse einen Konstruktor mit einemEmployeeJdbcDao-Parameter hat, der durch Erweitern derEmployeeDaoParameterResolver-Erweiterung aufgelöst wird.

Durch Hinzufügen derEnvironmentExtension wird unser Test nur in einer anderen Umgebung als“qa” ausgeführt.

In unserem Test wird auch die Tabelleemployeeserstellt und jede Methode durch Hinzufügen derEmployeeDatabaseSetupExtension in eine Transaktion eingeschlossen. Selbst wenn derwhenAddEmployee_thenGetEmploee()-Test zuerst ausgeführt wird, wodurch ein Datensatz zur Tabelle hinzugefügt wird, findet der zweite Test 0 Datensätze in der Tabelle.

Eine Logger-Instanz wird unserer Klasse mithilfe vonLoggingExtension hinzugefügt.

Schließlich ignoriert unsere Testklasse alleFileNotFoundException-Instanzen, da sie die entsprechende Erweiterung hinzufügt.

5.1. Automatische Nebenstellenregistrierung

Wenn wir eine Erweiterung für alle Tests in unserer Anwendung registrieren möchten, können wir dies tun, indem wir den vollständig qualifizierten Namen zur Datei/META-INF/services/org.junit.jupiter.api.extension.Extensionhinzufügen:

com.example.extensions.LoggingExtension

Damit dieser Mechanismus aktiviert wird, müssen wir auch den Konfigurationsschlüssel vonjunit.jupiter.extensions.autodetection.enabledauf true setzen. Dies kann durch Starten der JVM mit der Eigenschaft -Djunit.jupiter.extensions.autodetection.enabled=true oder durch Hinzufügen eines Konfigurationsparameters zuLauncherDiscoveryRequest erfolgen:

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

5.2. Programmatische Erweiterungsregistrierung

Das Registrieren von Erweiterungen mithilfe von Anmerkungen ist zwar deklarativer und unauffälliger, hat jedoch einen erheblichen Nachteil:we can’t easily customize the extension behavior. Beispielsweise können wir mit dem aktuellen Erweiterungsregistrierungsmodell die Datenbankverbindungseigenschaften vom Client nicht akzeptieren.

Zusätzlich zu dem auf deklarativen Annotationen basierenden Ansatz bietet JUnit eine API zum Registrieren von Erweiterungenprogrammatically. . Zum Beispiel können wir dieJdbcConnectionUtil -Skala nachrüsten, um die Verbindungseigenschaften zu akzeptieren:

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

Außerdem sollten wir einen neuen Konstruktor für die SextensionEmployeeDatabaseSetupExtension hinzufügen, um angepasste Datenbankeigenschaften zu unterstützen:

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

Um die Mitarbeitererweiterung mit benutzerdefinierten Datenbankeigenschaften zu registrieren, sollten Sie ein statisches Feld mit der Sannotation @RegisterExtension versehen:

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

Hier stellen wir eine Verbindung zu einer speicherinternen H2-Datenbank her, um die Tests auszuführen.

5.3. Registrierung Bestellung

JUnit registers @RegisterExtension static fields after registering extensions that are declaratively defined using the @ExtendsWith annotation. Wir können auch nicht statische Felder für die programmatische Registrierung verwenden, diese werden jedoch nach der Instanziierung der Testmethode und den Postprozessoren registriert.

Wenn wir mehrere Erweiterungen programmgesteuert über@RegisterExtension registrieren, registriert JUnit diese Erweiterungen in einer deterministischen Reihenfolge. Obwohl die Reihenfolge deterministisch ist, ist der für die Reihenfolge verwendete Algorithmus nicht offensichtlich und intern. Zuenforce 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
}

Hier sind Erweiterungenordered based on priority, where a lower value has greater priority than a higher value. Außerdem hätten Erweiterungen ohne@Order -Sannotation die niedrigstmögliche Priorität.

6. Fazit

In diesem Lernprogramm haben wir gezeigt, wie Sie mithilfe des JUnit 5-Erweiterungsmodells benutzerdefinierte Testerweiterungen erstellen können.

Der vollständige Quellcode der Beispiele istover on GitHub.