Parametrisierte Tests: Unterschied zwischen den Versionen

Aus MimiPedia
 
(2 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 47: Zeile 47:
Anstelle von Strings sind hier auch alle numerischen, primitiven Typen int, long, float und einiges mehr erlaubt.
Anstelle von Strings sind hier auch alle numerischen, primitiven Typen int, long, float und einiges mehr erlaubt.
Die Anntotations-Parameter kann man in Eclipse mit Code-Assist erkunden oder in der JUnit5-Doku nachlesen.
Die Anntotations-Parameter kann man in Eclipse mit Code-Assist erkunden oder in der JUnit5-Doku nachlesen.
===Und was ist mit null?===
=== Und was ist mit null? ===
Die eben vorgestellte Werte-Liste hat einen kleinen Haken: Es ist nicht möglich, den Wert null als Argument zu
Die eben vorgestellte Werte-Liste hat einen kleinen Haken: Es ist nicht möglich, den Wert null als Argument zu
verwenden. Um einen Testfall für null zu erzeugen, kann man nun freilich eine neue Test-Methode erstellen und
verwenden. Um einen Testfall für null zu erzeugen, kann man nun freilich eine neue Test-Methode erstellen und
null separat in diesem Test prüfen. Man kann dem Test aber auch zusätzlich die Annotation @NullSource
null separat in diesem Test prüfen. Man kann dem Test aber auch zusätzlich die Annotation {{java|@NullSource}}
hinzufügen. JUnit5 erzeugt dann einen zusätzlichen Testfall für null:
hinzufügen. JUnit5 erzeugt dann einen zusätzlichen Testfall für null:
{{java|code=
{{java|code=
Zeile 63: Zeile 63:


== Mehrere Parameter für die Test-Methode ==
== Mehrere Parameter für die Test-Methode ==
Dieser Abschnitt beschreibt die einfachste Variante, weitere Möglichkeiten werden [[Testdaten-Provider|hier]] beschrieben.
Für viele Unit-Tests reicht die Extraktion eines einzelnen Parameters nicht aus. Im folgenden Beispiel wird die
Für viele Unit-Tests reicht die Extraktion eines einzelnen Parameters nicht aus. Im folgenden Beispiel wird die
Qualität von Passworten getestet. Das Wort "Hallo" zum Beispiel wird abgewiesen, weil es nur Buchstaben enthält:
Qualität von Passworten getestet. Das Wort "Hallo" zum Beispiel wird abgewiesen, weil es nur Buchstaben enthält:
Zeile 131: Zeile 133:
Betrachten wir ein enum das Versandwege beschreibt:
Betrachten wir ein enum das Versandwege beschreibt:
{{java|code=
{{java|code=
enum Versandweg {
    enum Versandweg {
    E_MAIL,
        E_MAIL,
    SMS,
        SMS,
    POST
        POST
}
    }
}}
}}
und die zu testende Methode die eine TAN über einen Versandweg verschickt:
und die zu testende Methode die eine TAN über einen Versandweg verschickt:

Aktuelle Version vom 19. März 2024, 20:19 Uhr

Ein einzelner Unit-Tests befaßt sich mit einem einzelnen Test-Fall und hat im Regelfall eine einzelne Assertion. Dieses einfache Beispiel testet die korrekte Konvertierung von Datum-Strings in ein Date-Objekt. Die drei Assertions prüfen hier zusammen, ob das Datum korrekt ist:

@Test
public void toDateLiefertKorrektesDatum() {
    Date convertedDate = Convert.toDate("01.5.1985");
    assertThat(convertedDate).hasDayOfMonth(1);
    assertThat(convertedDate).hasMonth(5);
    assertThat(convertedDate).hasYear(1985);
}

Nun soll er Konverter mit unterschiedlichen Datums-Angaben umgehen: Mit und ohne führende Nullen, mit und ohne Jahrhundert, vielleicht soll auch ein Minuszeichen anstelle des Punkts erlaubt sein. Für jeden Testfall kann man nun die Test-Methode kopieren und entsprechend anpassen. Die Folgen sind klar: jede Menge Test-Code, sinkende Übersichtlichkeit und für jede neue Test-Methode braucht es einen neuen Namen. Das DRY-Prinzip soll auch für Test-Code gelten.

Eine wesentliche Bereicherung von JUnit-5 ist die Unterstützung von Parameter-gesteuerten Test-Methoden die weit über das hinausgeht was JUnit-4 oder Test-NG zu bieten hatten. Eine Vielzahl von Test-Situationen läßt sich damit abdecken und vor allem eindampfen.

Eine Liste einzelner Parameter

Betrachten wir –- angelehnt an das einleitende Beispiel -– die einfachste Situation: Einen Testfall soll mit einer Reihe einzelner Strings aufgerufen werden. Dazu parametrisieren wir zunächst die Test-Methode und ziehen den Datum-String als Parameter heraus:

public void toDateLiefertKorrektesDatum(String datum) {
    Date convertedDate = Convert.toDate(datum);
    assertThat(convertedDate).hasDayOfMonth(1);
    assertThat(convertedDate).hasMonth(5);
    assertThat(convertedDate).hasYear(1985);
}

Dann legen wir die Datum-Strings fest mit denen getestet werden soll: "1.5.1985", "01.5.1985", "1.05.1985", "01.05.1985" Um die neue Test-Methode mit den Test-Fälle zu verknüpfen verwenden wir JUnit5-Annotations und setzen sie über unsere parametrisierte Test-Methode:

@ParameterizedTest
@ValueSource(strings = { "1.5.1985", "01.5.1985", "1.05.1985", "01.05.1985" })

Die erste Annotation ersetzt die @Test-Annotation und macht die Methode zum (parametrisierten) Test. Die zweite Annotation legt die Quelle der zu verwendenden Werte fest. In diesem Falle ist dies eine Liste von Strings. Der Test wird bei der Ausführung für jeden String einmal aufgerufen, wobei der jeweilige String als Argument übergeben wird. Anstelle von Strings sind hier auch alle numerischen, primitiven Typen int, long, float und einiges mehr erlaubt. Die Anntotations-Parameter kann man in Eclipse mit Code-Assist erkunden oder in der JUnit5-Doku nachlesen.

Und was ist mit null?

Die eben vorgestellte Werte-Liste hat einen kleinen Haken: Es ist nicht möglich, den Wert null als Argument zu verwenden. Um einen Testfall für null zu erzeugen, kann man nun freilich eine neue Test-Methode erstellen und null separat in diesem Test prüfen. Man kann dem Test aber auch zusätzlich die Annotation @NullSource hinzufügen. JUnit5 erzeugt dann einen zusätzlichen Testfall für null:

@ParameterizedTest
@NullSource
@ValueSource(strings = { "1.5.1985", "01.5.1985", "1.05.1985", "01.05.1985" })
public void toDateLiefertKorrektesDatum(String datum) {
    // hier kommt der Test-Code hin...
}

Unterstützt wird @NullSource ab Version 5.4

Mehrere Parameter für die Test-Methode

Dieser Abschnitt beschreibt die einfachste Variante, weitere Möglichkeiten werden hier beschrieben.

Für viele Unit-Tests reicht die Extraktion eines einzelnen Parameters nicht aus. Im folgenden Beispiel wird die Qualität von Passworten getestet. Das Wort "Hallo" zum Beispiel wird abgewiesen, weil es nur Buchstaben enthält:

public void passwortWirdNichtAkzeptiert() {
    assertThat(policy.validate("Hallo")).as("nur Buchstaben").isFalse();
}

Wofür die Parameter in der Test-Methode verwendet werden, spielen keine Rolle, es geht nur darum, den Test mehrfach aufzurufen und dabei mehrere Parameter zu übergeben. Im vorliegenden Falle möchten wir unterschiedliche Passwort-Kandidaten testen und dabei jeden Test mit einer Beschreibung versehen, der Auskunft gibt warum das Wort nicht als Passwort akzeptiert wird. Die Test-Methode sieht nun so aus:

public void passwortWirdNichtAkzeptiert(String passwort, String grund) {
    assertThat(policy.validate(passwort)).as(grund).isFalse();
}

Für die Erzeugung der Test-Daten schreiben wir diesmal eine weitere Methode. Sie erzeugt einen Stream von Arguments-Objekten von denen jedes die Daten für einen Test-Aufruf enthält:

static Stream<Arguments> pwdWirdNichtAkzeptiert() {
    return Stream.of(
        Arguments.of("Hallo", "nur Buchstaben"),
        Arguments.of("1234567", "nur Ziffern"),
        Arguments.of("!%#&/()", "nur Sonderzeichen")
    );
}

Die Methode für die Testdaten-Erzeugung muß folgende Eigenschaften haben:

  • sie muß static deklariert sein
  • sie muß zur Test-Klasse gehören
  • sie darf keinen Parameter haben und muß einen Stream von Arguments-Objekten liefern
  • sie darf in beliebiger Sichtbarkeit deklariert werden

Die Methode darf zwar private deklariert werden, Java wird sich aber darüber beschweren, daß die Methode nirgends verwendet wird. Warum das so ist, erfahren wir weiter unten. Am einfachsten deklariert man sie daher package visible.

Die Klasse org.junit.jupiter.params.provider.Arguments ist ein Container, der eine Reihe von Werten beliebigen Typs (mit Ausnahme von λ-Ausdrücken) in fester Reihenfolge aufnimmt. JUnit5 mappt die Werte der Arguments-Objekte auf die Parameter der Test-Methode. Das geschieht allerdings erst zur Laufzeit, der Entwickler ist daher selbst dafür verantwortlich die Typen zwischen Testdaten-Provider und Test-Methode in Übereinstimmung zu halten.

Wir bringen nun Test-Methode und Testdaten-Provider-Methode zusammen. Das geschieht mithilfe folgender JUnit5-Annotationen:

@ParameterizedTest
@MethodSource("pwdWirdNichtAkzeptiert")

Die erste Annotation kennen wir schon aus dem vorangegangenen Abschnitt. Die zweite Annotation weist die Test-Methode an die Testdaten aus der Methode "pwdWirdNichtAkzeptiert" zu beziehen. Der Name der Methode wird als String angegeben. Das bedeutet, daß der Entwickler für die korrekte Schreibweise verantwortlich ist, Fehler treten auch hier erst zur Laufzeit auf. Hat die Test-Daten-Methode den gleichen Namen wie die Test-Methode kann sie in der Annotation auch weggelassen werden. Die Annotation selbst hingegen kann nicht weggelassen werden. Da die Methode nur über den Namen referenziert wird, muß der Compiler tatsächlich annehmen, daß die Method nicht verwendet wird wenn sie als private deklariert wurde.

Lambda-Ausdrücke

Weitere Test-Daten-Provider

JUnit5 unterstützt noch eine Reihe weiterer Möglichkeiten, Testdaten für parametrisierte Tests zur Verfügung zu stellen:

  • Java-enums
  • CSV-Dateien
  • Daten im CSV-Format
  • separate Klassen

Enums

Eine besondere Unterstützung bei der Erstellung parametrisierter Tests erfahren die Java-enums. Grundsätzlich erfolgt die Parametrisierung wie dies hier am Beispiel von String-Argumenten beschrieben wurde. Auf die spezifischen Eigenschaften von enums und ihrem besonderen Wert für Unit-Tests wird hier eingegangen. Betrachten wir ein enum das Versandwege beschreibt:

enum Versandweg {
        E_MAIL,
        SMS,
        POST
    }

und die zu testende Methode die eine TAN über einen Versandweg verschickt:

public class Versender {
    Response sendTan(Versandweg weg) {
        ...
    }
 }

Alle Ausprägungen testen

Wir können nun für jeden Versandweg eine Test-Methode schreiben. Das geht auch einfacher, wenn wir immer das gleiche Ergebnis erwarten:

@ParameterizedTest
 @EnumSource(value=Versandweg.class)
 void verwandWegLiefertOk(Versandweg weg) {
    Assertions.assertThat(new Versender().sendTan(weg)).isEqualTo(Response.OK);
 }

Die erste Annotation weist die Methode als parametrisierten Test aus. Die zweite Annotation gibt an, welches enum für den Test zu verwenden ist. Die Test-Methode selbst muß ein Argument vom Typ des Enums akzeptieren. Die parametrisierte Version ist nicht nur kompakter. Wird das enum um weitere Ausprägungen ergänzt, werden automatisch Test dafür generiert. Ob das Ergebnis tatsächlich der gewünschten Anforderung entspricht kann JUnit natürlich nicht vorhersehen, aber die neue enum-Ausprägung wird zumindest betrachtet und läuft günstigenfalls auf einen Fehler.

Einzelne Ausprägungen testen

Möchte man nicht alle Ausprägungen testen sondern nur ganz bestimmte, kann man das der Annotation mitgeben. dafür gibt es zwei Modes: einschließend und ausschließend. Möchten wir etwa nur die Versandwege eMail und SMS testen, können wir die Annotation so schreiben (der ganze Rest bleibt gleich):

@EnumSource(value = Versandweg.class, mode = Mode.INCLUDE, names = { "E_MAIL", "SMS" })

Der Parameter mod gibt an, daß nur die angegebenen Ausprägungen getestet werden sollen. Da "Include" der default ist, kann de Mo in diesem Falle auch weggelassen werden. Der Parameter names ist eine Liste von enum-Ausprägung-Namen die als Strings angegeben werden. Man kann das vorgehen auch umdrehen un den Versandweg "POST" – als einzigen nicht-elektronischen Weg ausschließen. Die Annotation sieht dann so aus:

@EnumSource(value = Versandweg.class, mode = Mode.EXCLUDE, names = { "POST" })

Der mode-Parameter hat nun den Wert EXCLUDE und darf natürlich nicht weggelassen werden. Der Parameter names ist wieder eine Liste von enum-Ausprägung-Namen die hier aus einem einzigen Wert besteht. In beiden Fällen ist der Entwickler für die korrekte Schreibweise der Namen der enum-Ausprägungen verantwortlich. Stimmt sie nicht überein, giebt's einen Laufzeitfehler bei der Test-Ausführung. Während bei "INCLUDE" nur so viel Tests erzeugt werden, wie enum-Ausprägungen spezifiziert wurden, werden bei "EXCLUDE" Tests für alle Ausprägungen erzeugt die nicht angegeben sind. Wird das enum um Ausprägungen erweitert, dann werden im EXCLUDE-Falle neue Tests generiert, im INCLUDE-Falle nicht.

Pattern-Matching

Mit den beiden Modes MATCH_ALL und MATCH_ANY können die Namen von enum-Ausprägungen mit Hilfe von regulären Ausdrücken spezifiziert werden. Dieses Vorgehen ist jedoch problematisch und soll hier nicht weiter erörtert werden. Macht man bei Definition der Ausdrücke Fehler, kann es beispielsweise passieren, daß Ausprägungen ausgelassen werden und für diese dann keine Tests erzeugt werden. Das fällt nur auf wenn die TestAusführung manuell überprüft wird.

Test-Fälle als Enumerationen

bei der Verwendung von enum-Sources ist man nicht auf "produktive" enums beschränkt. Man kann auch Test-Situationen über enums definieren und diese enums dann verwenden um parametrisierte Tests zu füttern. Man kann zum Beispiel für die Test-Klasse enum "Kunde" definieren, dessen Ausprägungen jeweils einzelne Test-Konstellationen beschreiben. Damit lassen sich parametrisierte Tests definieren, die alle oder ausgewählte Konstellationen testen:

@ParameterizedTest
 @EnumSource(value = Kunde.class, names = { "MIT_KDNR", "MIT_IHNR", "MIT_KDNR_UND_IHNR" })
 void darfElektronischeDokumentEinstellungSehen(Kunde kunde) {
    assertThat(verwalter(kunde).darfElectronicDocumentsSehen()).isTrue();
 }

In diesem Beispiel wurde Kunden konfiguriert die ein Kunden- oder Inhabernummer oder beides haben. Jeder der Kunden muß seine elektronischen Dokumente sehen dürfen.