Subsections of Testen mit JUnit und Mockito

Einführung Softwaretest

TL;DR

Fehler schleichen sich durch Zeitdruck und hohe Komplexität schnell in ein Softwareprodukt ein. Die Folgen können von "ärgerlich" über "teuer" bis hin zu (potentiell) "tödlich" reichen. Richtiges Testen ist also ein wichtiger Aspekt bei der Softwareentwicklung!

JUnit ist ein Java-Framework, mit dem Unit-Tests (aber auch andere Teststufen) implementiert werden können. In JUnit 4 und 5 zeichnet man eine Testmethode mit Hilfe der Annotation @Test an der entsprechenden Methode aus. Dadurch kann man Produktiv- und Test-Code prinzipiell mischen; Best Practice ist aber das Anlegen eines weiteren Ordners test/ und das Spiegeln der Package-Strukturen. Für die zu testende Klasse wird eine korrespondierende Testklasse mit dem Suffix "Test" (Konvention) angelegt und dort die Testmethoden implementiert. Der IDE muss der neue test/-Ordner noch als Ordner für Sourcen bzw. Tests bekannt gemacht werden. In den Testmethoden baut man den Test auf, führt schließlich den Testschritt durch (beispielsweise konkreter Aufruf der zu testenden Methode) und prüft anschließend mit einem assert*(), ob das erzielte Ergebnis dem erwarteten Ergebnis entspricht. Ist alles OK, ist der Test "grün", sonst "rot".

Da ein fehlschlagendes assert*() den Test abbricht, werden eventuell danach folgende Prüfungen nicht mehr durchgeführt und damit ggf. weitere Fehler maskiert. Deshalb ist es gute Praxis, in einer Testmethode nur einen Testfall zu implementieren und i.d.R. auch nur ein (oder wenige) Aufrufe von assert*() pro Testmethode zu haben.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Ursachen von Softwarefehlern
  • (K3) Aufbauen von Tests mit JUnit 4 und 5 unter Nutzung der Annotation @Test

Software-Fehler und ihre Folgen

(Einige) Ursachen für Fehler

  • Zeit- und Kostendruck
  • Mangelhafte Anforderungsanalyse
  • Hohe Komplexität
  • Mangelhafte Kommunikation
  • Keine/schlechte Teststrategie
  • Mangelhafte Beherrschung der Technologie
  • ...

Irgendjemand muss mit Deinen Bugs leben!

Leider gibt es im Allgemeinen keinen Weg zu zeigen, dass eine Software korrekt ist. Man kann (neben formalen Beweisansätzen) eine Software nur unter möglichst vielen Bedingungen ausprobieren, um zu schauen, wie sie sich verhält, und um die dabei zu Tage tretenden Bugs zu fixen.

Mal abgesehen von der verbesserten User-Experience führt weniger fehlerbehaftete Software auch dazu, dass man seltener mitten in der Nacht geweckt wird, weil irgendwo wieder ein Server gecrasht ist ... Weniger fehlerbehaftete Software ist auch leichter zu ändern und zu pflegen! In realen Projekten macht Maintenance den größten Teil an der Softwareentwicklung aus ... Während Ihre Praktikumsprojekte vermutlich nach der Abgabe nie wieder angeschaut werden, können echte Projekte viele Jahre bis Jahrzehnte leben! D.h. irgendwer muss sich dann mit Ihren Bugs herumärgern - vermutlich sogar Sie selbst ;)

Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live. Code for readability.

-- John F. Woods

Dieses Zitat taucht immer mal wieder auf, beispielsweise auf der OSCON 2014 ... Es scheint aber tatsächlich, dass John F. Woods die ursprüngliche Quelle war (vgl. Stackoverflow: 876089).

Da wir nur wenig Zeit haben und zudem vergesslich sind und obendrein die Komplexität eines Projekts mit der Anzahl der Code-Zeilen i.d.R. nicht-linear ansteigt, müssen wir das Testen automatisieren. Und hier kommt JUnit ins Spiel :)

Was wann testen? Wichtigste Teststufen

  • Modultest

    • Testen einer Klasse und ihrer Methoden
    • Test auf gewünschtes Verhalten (Parameter, Schleifen, ...)
  • Integrationstest

    • Test des korrekten Zusammenspiels mehrerer Komponenten
    • Konzentration auf Schnittstellentests
  • Systemtest

    • Test des kompletten Systems unter produktiven Bedingungen
    • Orientiert sich an den aufgestellten Use Cases
    • Funktionale und nichtfunktionale Anforderungen testen

=> Verweis auf Wahlfach "Softwarequalität"

JUnit: Test-Framework für Java

JUnit --- Open Source Java Test-Framework zur Erstellung und Durchführung wiederholbarer Tests

  • JUnit 3

    • Tests müssen in eigenen Testklassen stehen
    • Testklassen müssen von Klasse TestCase erben
    • Testmethoden müssen mit dem Präfix "test" beginnen
  • JUnit 4

    • Annotation @Test für Testmethoden
    • Kein Zwang zu spezialisierten Testklassen (insbesondere kein Zwang mehr zur Ableitung von TestCase)
    • Freie Namenswahl für Testmethoden (benötigen nicht mehr Präfix "test")

    Damit können prinzipiell auch direkt im Source-Code Methoden als JUnit-Testmethoden ausgezeichnet werden ... (das empfiehlt sich in der Regel aber nicht)

  • JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

    • Erweiterung um mächtigere Annotationen
    • Aufteilung in spezialisierte Teilprojekte

    Das Teilprojekt "JUnit Platform" ist die Grundlage für das JUnit-Framework. Es bietet u.a. einen Console-Launcher, um Testsuiten manuell in der Konsole zu starten oder über Builder wie Ant oder Gradle.

    Das Teilprojekt "JUnit Jupiter" ist das neue Programmiermodell zum Schreiben von Tests in JUnit 5. Es beinhaltet eine TestEngine zum Ausführen der in Jupiter geschriebenen Tests.

    Das Teilprojekt "JUnit Vintage" beinhaltet eine TestEngine zum Ausführen von Tests, die in JUnit 3 oder JUnit 4 geschrieben sind.

Anmerkung: Wie der Name schon sagt, ist das Framework für Modultests ("Unit-Tests") gedacht. Man kann damit aber auch auf anderen Teststufen arbeiten!

Anmerkung: Im Folgenden besprechen wir JUnit am Beispiel JUnit 4, da diese Version des Frameworks besonders stark verbreitet ist und JUnit 5 (trotz offiziellem Release) immer noch stellenweise unfertig wirkt. Auf Unterschiede zu JUnit 5 wird an geeigneter Stelle hingewiesen (abgesehen von Import-Statements). Mit JUnit 3 sollte nicht mehr aktiv gearbeitet werden, d.h. insbesondere keine neuen Tests mehr erstellt werden, da diese Version nicht mehr weiterentwickelt wird.

Anlegen und Organisation der Tests mit JUnit

  • Anlegen neuer Tests: Klasse auswählen, Kontextmenü New > JUnit Test Case

  • Best Practice:  Spiegeln der Paket-Hierarchie

    • Toplevel-Ordner test (statt src)
    • Package-Strukturen spiegeln
    • Testklassen mit Suffix "Test"

Vorteile dieses Vorgehens:

  • Die Testklassen sind aus Java-Sicht im selben Package wie die Source-Klassen, d.h. Zugriff auf Package-sichtbare Methoden etc. ist gewährleistet
  • Durch die Spiegelung der Packages in einem separaten Testordner erhält man eine gute getrennte Übersicht über jeweils die Tests und die Sourcen
  • Die Wiederverwendung des Klassennamens mit dem Anhang "Test" erlaubt die schnelle Erkennung, welche Tests hier vorliegen

In der Paketansicht liegen dann die Source- und die Testklassen immer direkt hintereinander (da sie im selben Paket sind und mit dem selben Namen anfangen) => besserer Überblick!

Anmerkung: Die (richtige) JUnit-Bibliothek muss im Classpath liegen!

Eclipse bringt für JUnit 4 und JUnit 5 die nötigen Jar-Dateien mit und fragt beim erstmaligen Anlegen einer neuen Testklasse, ob die für die ausgewählte Version passenden JUnit-Jars zum Build-Path hinzugefügt werden sollen.

IntelliJ bringt ebenfalls eine JUnit 4 Bibliothek mit, die zum Projekt als Abhängigkeit hinzugefügt werden muss. Für JUnit 5 bietet IntelliJ an, die Jar-Dateien herunterzuladen und in einem passenden Ordner abzulegen.

Alternativ lädt man die Bibliotheken entsprechend der Anleitung unter junit.org herunter und bindet sie in das Projekt ein.

JUnit 4+5: Definition von Tests

Annotation @Test vor Testmethode schreiben

import org.junit.Test;
import static org.junit.Assert.*;

public class FactoryBeispielTest4 {
    @Test
    public void testGetTicket() {
        fail("not implemented");
    }
}

Für JUnit 5 muss statt org.junit.Test entsprechend org.junit.jupiter.api.Test importiert werden.

Während in JUnit 4 die Testmethoden mit der Sichtbarkeit public versehen sein müssen und keine Parameter haben (dürfen), spielt die Sichtbarkeit in JUnit 5 keine Rolle (und die Testmethoden dürfen Parameter aufweisen => vgl. Abschnitt "Dependency Injection for Constructors and Methods" in der JUnit-Doku).

JUnit 4: Ergebnis prüfen

Klasse org.junit.Assert enthält diverse statische Methoden zum Prüfen:

// Argument muss true bzw. false sein
void assertTrue(boolean);
void assertFalse(boolean);

// Gleichheit im Sinne von equals()
void assertEquals(Object, Object);

// Test sofort fehlschlagen lassen
void fail();

...

Für JUnit 5 finden sich die Assert-Methoden im Package org.junit.jupiter.api.Assertions.

Anmerkung zum statischen Import

Bei normalem Import der Klasse Assert muss man jeweils den voll qualifizierten Namen einer statischen Methode nutzen: Assert.fail().

Alternative statischer Import: import static org.junit.Assert.fail; => Statische Member der importierten Klasse (oder Interface) werden über ihre unqualifizierten Namen zugreifbar. Achtung: Namenskollisionen möglich!

// nur bestimmtes Member importieren
import static packageName.className.staticMemberName;
// alle statischen Member importieren
import static packageName.className.*;
  • Beispiel normaler Import:

    import org.junit.Assert;
    Assert.fail("message");
  • Beispiel statischer Import:

    import static org.junit.Assert.fail;
    fail("message");

Mögliche Testausgänge bei JUnit

  1. Error: Fehler im Programm (Test)

    • Unbehandelte Exception
    • Abbruch (Timeout)
  2. Failure: Testausgang negativ

    • Assert fehlgeschlagen
    • Assert.fail() aufgerufen
  3. OK

Anmerkungen zu Assert

  • Pro Testmethode möglichst nur ein Assert verwenden!
  • Anderenfalls: Schlägt ein Assert fehl, wird der Rest nicht mehr überprüft ...

Wrap-Up

  • Testen ist genauso wichtig wie Coden

  • Richtiges Testen spart Geld, Zeit, ...

  • Tests auf verschiedenen Abstraktionsstufen

  • JUnit als Framework für (Unit-) Tests; hier JUnit 4 (mit Ausblick auf JUnit 5)

    • Testmethoden mit Annotation @Test
    • Testergebnis mit assert* prüfen
Challenges

Einfache JUnit-Tests

Betrachten Sie die folgende einfache (und nicht besonders sinnvolle) Klasse MyList<T>:

public class MyList<T> {
    protected final List<T> list = new ArrayList<>();

    public boolean add(T element) { return list.add(element); }
    public int size() { return list.size(); }
}

Schreiben Sie mit Hilfe von JUnit (4.x oder 5.x) einige Unit-Tests für die beiden Methoden MyList<T>#add und MyList<T>#size.

Quellen

Testen mit JUnit (JUnit-Basics)

TL;DR

In JUnit 4 und 5 werden Testmethoden mit Hilfe der Annotation @Test ausgezeichnet. Über die verschiedenen assert*()-Methoden kann das Testergebnis mit dem erwarteten Ergebnis verglichen werden und entsprechend ist der Test "grün" oder "rot". Mit den verschiedenen assume*()-Methoden kann dagegen geprüft werden, ob eventuelle Vorbedingungen für das Ausführen eines Testfalls erfüllt sind - anderenfalls wird der Testfall dann übersprungen.

Mit Hilfe von @Before und @After können Methoden gekennzeichnet werden, die jeweils vor jeder Testmethode und nach jeder Testmethode aufgerufen werden. Damit kann man seine Testumgebung auf- und auch wieder abbauen (JUnit 4).

Erwartete Exceptions lassen sich in JUnit 4 mit einem Parameter expected in der Annotation @Test automatisch prüfen: @Test(expected=package.Exception.class). In JUnit 4 besteht die Möglichkeit, Testklassen zu Testsuiten zusammenzufassen und gemeinsam laufen zu lassen.

Videos (HSBI-Medienportal)
Lernziele
  • (K3) Steuern von Tests (ignorieren, zeitliche Begrenzung)
  • (K3) Prüfung von Exceptions
  • (K3) Aufbau von Testsuiten mit JUnit

JUnit: Ergebnis prüfen

Klasse org.junit.Assert enthält diverse statische Methoden zum Prüfen:

// Argument muss true bzw. false sein
void assertTrue(boolean);
void assertFalse(boolean);

// Gleichheit im Sinne von equals()
void assertEquals(Object, Object);

// Test sofort fehlschlagen lassen
void fail();

...

To "assert" or to "assume"?

  • Mit assert* werden Testergebnisse geprüft

    • Test wird ausgeführt
    • Ergebnis: OK, Failure, Error
  • Mit assume* werden Annahmen über den Zustand geprüft

    • Test wird abgebrochen, wenn Annahme nicht erfüllt
    • Prüfen von Vorbedingungen: Ist der Test hier ausführbar/anwendbar?

Setup und Teardown: Testübergreifende Konfiguration

private Studi x;

@Before
public void setUp() { x = new Studi(); }

@Test
public void testToString() {
    // Studi x = new Studi();
    assertEquals(x.toString(), "Heinz (15cps)");
}
@Before
wird vor jeder Testmethode aufgerufen
@BeforeClass
wird einmalig vor allen Tests aufgerufen (static!)
@After
wird nach jeder Testmethode aufgerufen
@AfterClass
wird einmalig nach allen Tests aufgerufen (static!)

In JUnit 5 wurden die Namen dieser Annotationen leicht geändert:

JUnit 4 JUnit 5
@Before @BeforeEach
@After @AfterEach
@BeforeClass @BeforeAll
@AfterClass @AfterAll

Beispiel für den Einsatz von @Before

Annahme: alle/viele Testmethoden brauchen neues Objekt x vom Typ Studi

private Studi x;

@Before
public void setUp() {
    x = new Studi("Heinz", 15);
}

@Test
public void testToString() {
    // Studi x = new Studi("Heinz", 15);
    assertEquals(x.toString(), "Name: Heinz, credits: 15");
}

@Test
public void testGetName() {
    // Studi x = new Studi("Heinz", 15);
    assertEquals(x.getName(), "Heinz");
}

Ignorieren von Tests

  • Hinzufügen der Annotation @Ignore
  • Alternativ mit Kommentar: @Ignore("Erst im nächsten Release")
@Ignore("Warum ignoriert")
@Test
public void testBsp() {
    Bsp x = new Bsp();
    assertTrue(x.isTrue());
}

In JUnit 5 wird statt der Annotation @Ignore die Annotation @Disabled mit der selben Bedeutung verwendet. Auch hier lässt sich als Parameter ein String mit dem Grund für das Ignorieren des Tests hinterlegen.

Vermeidung von Endlosschleifen: Timeout

  • Testfälle werden nacheinander ausgeführt
  • Test mit Endlosschleife würde restliche Tests blockieren
  • Erweitern der @Test-Annotation mit Parameter "timeout": => @Test(timeout=2000) (Zeitangabe in Millisekunden)
@Test(timeout = 2000)
void testTestDauerlaeufer() {
    while (true) { ; }
}

In JUnit 5 hat die Annotation @Test keinen timeout-Parameter mehr. Als Alternative bietet sich der Einsatz von org.junit.jupiter.api.Assertions.assertTimeout an. Dabei benötigt man allerdings Lambda-Ausdrücke (Verweis auf spätere VL):

@Test
void testTestDauerlaeufer() {
    assertTimeout(ofMillis(2000), () -> {
        while (true) { ; }
    });
}

(Beispiel von oben mit Hilfe von JUnit 5 formuliert)

Test von Exceptions: Expected

Traditionelles Testen von Exceptions mit try und catch:

@Test
public void testExceptTradit() {
    try {
        int i = 0 / 0;
        fail("keine ArithmeticException ausgeloest");
    } catch (ArithmeticException aex) {
        assertNotNull(aex.getMessage());
    } catch (Exception e) {
        fail("falsche Exception geworfen");
    }
}

Der expected-Parameter für die @Test-Annotation in JUnit 4 macht dies deutlich einfacher: @Test(expected = MyException.class) => Test scheitert, wenn diese Exception nicht geworfen wird

@Test(expected = java.lang.ArithmeticException.class)
public void testExceptAnnot() {
    int i = 0 / 0;
}

In JUnit 5 hat die Annotation @Test keinen expected-Parameter mehr. Als Alternative bietet sich der Einsatz von org.junit.jupiter.api.Assertions.assertThrows an. Dabei benötigt man allerdings Lambda-Ausdrücke (Verweis auf spätere VL):

@Test
public void testExceptAnnot() {
    assertThrows(java.lang.ArithmeticException.class, () -> {
        int i = 0 / 0;
    });
}

(Beispiel von oben mit Hilfe von JUnit 5 formuliert)

Parametrisierte Tests

Manchmal möchte man den selben Testfall mehrfach mit anderen Werten (Parametern) durchführen.

class Sum {
    public static int sum(int i, int j) {
        return i + j;
    }
}

class SumTest {
    @Test
    public void testSum() {
        Sum s = new Sum();
        assertEquals(s.sum(1, 1), 2);
    }
    // und mit (2,2, 4), (2,2, 5), ...????
}

Prinzipiell könnte man dafür entweder in einem Testfall eine Schleife schreiben, die über die verschiedenen Parameter iteriert. In der Schleife würde dann jeweils der Aufruf der zu testenden Methode und das gewünschte Assert passieren. Alternativ könnte man den Testfall entsprechend oft duplizieren mit jeweils den gewünschten Werten.

Beide Vorgehensweisen haben Probleme: Im ersten Fall würde die Schleife bei einem Fehler oder unerwarteten Ergebnis abbrechen, ohne dass die restlichen Tests (Werte) noch durchgeführt würden. Im zweiten Fall bekommt man eine unnötig große Anzahl an Testmethoden, die bis auf die jeweiligen Werte identisch sind (Code-Duplizierung).

Parametrisierte Tests mit JUnit 4

JUnit 4 bietet für dieses Problem sogenannte "parametrisierte Tests" an. Dafür muss eine Testklasse in JUnit 4 folgende Bedingungen erfüllen:

  1. Die Testklasse wird mit der Annotation @RunWith(Parameterized.class) ausgezeichnet.
  2. Es muss eine öffentliche statische Methode geben mit der Annotation @Parameters. Diese Methode liefert eine Collection zurück, wobei jedes Element dieser Collection ein Array mit den Parametern für einen Durchlauf der Testmethoden ist.
  3. Die Parameter müssen gesetzt werden. Dafür gibt es zwei Varianten:
    • (A) Für jeden Parameter gibt es ein öffentliches Attribut. Diese Attribute müssen mit der Annotation @Parameter markiert sein und können in den Testmethoden normal genutzt werden. JUnit sorgt dafür, dass für jeden Eintrag in der Collection aus der statischen @Parameters-Methode diese Felder gesetzt werden und die Testmethoden aufgerufen werden.
    • (B) Alternativ gibt es einen Konstruktor, der diese Werte setzt. Die Anzahl der Parameter im Konstruktor muss dabei exakt der Anzahl (und Reihenfolge) der Werte in jedem Array in der von der statischen @Parameters-Methode gelieferten Collection entsprechen. Der Konstruktor wird für jeden Parametersatz einmal aufgerufen und die Testmethoden einmal durchgeführt.

Letztlich wird damit das Kreuzprodukt aus Testmethoden und Testdaten durchgeführt.

(A) Parametrisierte Tests: Konstruktor (JUnit 4)

@RunWith(Parameterized.class)
public class SumTestConstructor {
    private final int s1;
    private final int s2;
    private final int erg;

    public SumTestConstructor(int p1, int p2, int p3) { s1 = p1;  s2 = p2;  erg = p3; }

    @Parameters
    public static Collection<Object[]> values() {
        return Arrays.asList(new Object[][] { { 1, 1, 2 }, { 2, 2, 4 }, { 2, 2, 5 } });
    }

    @Test
    public void testSum() {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

(B) Parametrisierte Tests: Parameter (JUnit 4)

@RunWith(Parameterized.class)
public class SumTestParameters {

    @Parameter(0)  public int s1;
    @Parameter(1)  public int s2;
    @Parameter(2)  public int erg;

    @Parameters
    public static Collection<Object[]> values() {
        return Arrays.asList(new Object[][] { { 1, 1, 2 }, { 2, 2, 4 }, { 2, 2, 5 } });
    }

    @Test
    public void testSum() {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

Parametrisierte Tests mit JUnit 5

In JUnit 5 werden parametrisierte Tests mit der Annotation @ParameterizedTest gekennzeichnet (statt mit @Test).

Mit Hilfe von @ValueSource kann man ein einfaches Array von Werten (Strings oder primitive Datentypen) angeben, mit denen der Test ausgeführt wird. Dazu bekommt die Testmethode einen entsprechenden passenden Parameter:

@ParameterizedTest
@ValueSource(strings = {"wuppie", "fluppie", "foo"})
void testWuppie(String candidate) {
    assertTrue(candidate.equals("wuppie"));
}

Alternativ lassen sich als Parameterquelle u.a. Aufzählungen (@EnumSource) oder Methoden (@MethodSource) oder auch Komma-separierte Daten (@CsvSource) angeben.

Das obige Beispiel aus JUnit 4.x könnte mit Hilfe von @CsvSource so in JUnit 5.x umgesetzt werden:

public class SumTest {
    @ParameterizedTest
    @CsvSource(textBlock = """
            # s1,  s2,  s1+s2
            0,     0,   0
            10,    0,   10
            0,     11,  11
            -2,    10,  8
            """)
    public void testSum(int s1, int s2, int erg) {
        assertEquals(Sum.sum(s1, s2), erg);
    }
}

Testsuiten: Tests gemeinsam ausführen (JUnit 4)

Eclipse: New > Other > Java > JUnit > JUnit Test Suite

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
@SuiteClasses({
    // Hier kommen alle Testklassen rein
    PersonTest.class,
    StudiTest.class
})

public class MyTestSuite {
    // bleibt leer!!!
}

Testsuiten mit JUnit 5

In JUnit 5 gibt es zwei Möglichkeiten, Testsuiten zu erstellen:

  • @SelectPackages: Angabe der Packages, die für die Testsuite zusammengefasst werden sollen
  • @SelectClasses: Angabe der Klassen, die für die Testsuite zusammengefasst werden sollen
@RunWith(JUnitPlatform.class)
@SelectClasses({StudiTest5.class, WuppieTest5.class})
public class MyTestSuite5 {
    // bleibt leer!!!
}

Zusätzlich kann man beispielsweise mit @IncludeTags oder @ExcludeTags Testmethoden mit bestimmten Tags einbinden oder ausschließen. Beispiel: Schließe alle Tests mit Tag "develop" aus: @ExcludeTags("develop"). Dabei wird an den Testmethoden zusätzlich das Tag @Tag verwendet, etwas @Tag("develop").

Achtung: Laut der offiziellen Dokumentation (Abschnitt "4.4.4. Test Suite") gilt zumindest bei der Selection über @SelectPackages der Zwang zu einer Namenskonvention: Es werden dabei nur Klassen gefunden, deren Name mit Test beginnt oder endet! Weiterhin werden Testsuites mit der Annotation @RunWith(JUnitPlatform.class) nicht auf der "JUnit 5"-Plattform ausgeführt, sondern mit der JUnit 4-Infrastuktur!

Best Practices

  1. Ein Testfall behandelt exakt eine Idee/ein Szenario. Das bedeutet auch, dass man in der Regel nur ein bis wenige assert* pro Testmethode benutzt.

    (Wenn man verschiedene Ideen in eine Testmethode kombiniert, wird der Testfall unübersichtlicher und auch auch schwerer zu warten.

    Außerdem können so leichter versteckte Fehler auftreten: Das erste oder zweite oder dritte assert* schlägt fehl - und alle dahinter kommenden assert* werden nicht mehr ausgewertet!)

  2. Wenn die selbe Testidee mehrfach wiederholt wird, sollte man diese Tests zu einem parametrisierten Test zusammenfassen.

    (Das erhöht die Lesbarkeit drastisch - und man läuft auch nicht in das Problem der Benennung der Testmethoden.)

  3. Es wird nur das Verhalten der öffentlichen Schnittstelle getestet, nicht die inneren Strukturen einer Klasse oder Methode.

    (Es ist verlockend, auch private Methoden zu testen und in den Tests auch die Datenstrukturen o.ä. im Blick zu behalten und zu testen. Das führt aber zu sehr "zerbrechlichen" (brittle) Tests: Sobald sich etwas an der inneren Struktur ändert, ohne dass sich das von außen beobachtbare Verhalten ändert und also die Klasse/Methode immer noch ordnungsgemäß funktioniert, gehen all diese "internen" Tests kaputt. Nicht ohne Grund wird in der objektorientierten Programmierung mit Kapselung (Klassen, Methoden, ...) gearbeitet.)

  4. Von Setup- und Teardown-Methoden sollte eher sparsam Gebrauch gemacht werden.

    (Normalerweise folgen wir in der objektorientierten Programmierung dem DRY-Prinzip (Don't repeat yourself). Entsprechend liegt es nahe, häufig benötigte Elemente in einer Setup-Methode zentral zu initialisieren und ggf. in einer Teardown-Methode wieder freizugeben.

    Das führt aber speziell bei Unit-Tests dazu, dass die einzelnen Testmethoden schwerer lesbar werden: Sie hängen von einer gemeinsamen, zentralen Konfiguration ab, die man üblicherweise nicht gleichzeitig mit dem Code der Testmethode sehen kann (begrenzter Platz auf der Bildschirmseite).

    Wenn nun in einem oder vielleicht mehreren Testfällen der Wunsch nach einer leicht anderen Konfiguration auftaucht, muss man die gemeinsame Konfiguration entsprechend anpassen bzw. erweitern. Dabei muss man dann aber alle anderen Testmethoden mit bedenken, die ja ebenfalls von dieser Konfiguration abhängen! Das führt in der Praxis dann häufig dazu, dass die gemeinsame Konfiguration sehr schnell sehr groß und verschachtelt und entsprechend unübersichtlich wird.

    Jede Änderung an dieser Konfiguration kann leicht einen oder mehrere Testfälle kaputt machen (man hat ja i.d.R. nie alle Testfälle gleichzeitig im Blick), weshalb man hier unbedingt mit passenden assume* arbeiten muss - aber dann kann man eigentlich auch stattdessen die Konfiguration direkt passend für den jeweiligen Testfall in der jeweiligen Testmethode erledigen!)

  5. Wie immer sollten auch die Namen der Testmethoden klar über ihren Zweck Auskunft geben.

    (Der Präfix "test" wird seit JUnit 4.x nicht mehr benötigt, aber dennoch ist es in vielen Projekten Praxis, diesen Präfix beizubehalten - damit kann man in der Package-Ansicht in der IDE leichter zwischen den "normalen" und den Testmethoden unterscheiden.)

Diese Erfahrungen werden ausführlich in [SWEGoogle, pp. 231-256] diskutiert.

Wrap-Up

JUnit als Framework für (Unit-) Tests; hier JUnit 4 (mit Ausblick auf JUnit 5)

  • Testmethoden mit Annotation @Test
  • assert (Testergebnis) vs. assume (Testvorbedingung)
  • Aufbau der Testumgebung @Before
  • Abbau der Testumgebung @After
  • Steuern von Tests mit @Ignore oder @Test(timout=XXX)
  • Exceptions einfordern mit @Test(expected=package.Exception.class)
  • Tests zusammenfassen zu Testsuiten
Challenges

Setup und Teardown

Sie haben in den Challenges in "Intro SW-Test" erste JUnit-Tests für die Klasse MyList<T> implementiert.

Wie müssten Sie Ihre JUnit-Tests anpassen, wenn Sie im obigen Szenario Setup- und Teardown-Methoden einsetzen würden?

Parametrisierte Tests

Betrachten Sie die folgende einfache Klasse MyMath:

public class MyMath {
    public static String add(String s, int c) {
        return s.repeat(c);
    }
}

Beim Testen der Methode MyMath#add fällt auf, dass man hier immer wieder den selben Testfall mit lediglich anderen Werten ausführt - ein Fall für parametrisierte Tests.

Schreiben Sie mit Hilfe von JUnit (4.x oder 5.x) einige parametrisierte Unit-Tests für die Methode MyMath#add.

Quellen

Testfallermittlung: Wie viel und was muss man testen?

TL;DR

Mit Hilfe der Äquivalenzklassenbildung kann man Testfälle bestimmen. Dabei wird der Eingabebereich für jeden Parameter einer Methode in Bereiche mit gleichem Verhalten der Methode eingeteilt (die sogenannten "Äquivalenzklassen"). Dabei können einige Äquivalenzklassen (ÄK) gültigen Eingabebereichen entsprechen ("gültige ÄK"), also erlaubten/erwarteten Eingaben (die zum gewünschten Verhalten führen), und die restlichen ÄK entsprechen dann ungültigen Eingabebereichen ("ungültige ÄK"), also nicht erlaubten Eingaben, die von der Methode zurückgewiesen werden sollten. Jede dieser ÄK muss in mindestens einem Testfall vorkommen, d.h. man bestimmt einen oder mehrere zufällige Werte in den ÄK. Dabei können über mehrere Parameter hinweg verschiedene gültige ÄK in einem Testfall kombiniert werden. Bei den ungültigen ÄK kann dagegen immer nur ein Parameter eine ungültige ÄK haben, für die restlichen Parameter müssen gültige ÄK genutzt werden, und diese werden dabei als durch diesen Testfall "nicht getestet" betrachtet.

Zusätzlich entstehen häufig Fehler bei den Grenzen der Bereiche, etwa in Schleifen. Deshalb führt man zusätzlich noch eine Grenzwertanalyse durch und bestimmt für jede ÄK den unteren und den oberen Grenzwert und erzeugt aus diesen Werten zusätzliche Testfälle.

Wenn in der getesteten Methode der Zustand des Objekts eine Rolle spielt, wird dieser wie ein weiterer Eingabeparameter für die Methode betrachtet und entsprechend in die ÄK-Bildung bzw. GW-Analyse einbezogen.

Wenn ein Testfall sich aus den gültigen ÄK/GW speist, spricht man auch von einem "Positiv-Test"; wenn ungültige ÄK/GW genutzt werden, spricht man auch von einem "Negativ-Test".

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (K2) Merkmale schlecht testbaren Codes erklären
  • (K2) Merkmale guter Unit-Tests erklären
  • (K3) Erstellen von Testfällen mittels Äquivalenzklassenbildung und Grenzwertanalyse

Hands-On (10 Minuten): Wieviel und was muss man testen?

public class Studi {
    private int credits = 0;

    public void addToCredits(int credits) {
        if (credits < 0) {
            throw new IllegalArgumentException("Negative Credits!");
        }
        if (this.credits + credits > 210) {
            throw new IllegalArgumentException("Mehr als 210 Credits!");
        }
        this.credits += credits;
    }
}

JEDE Methode mindestens testen mit/auf:

  • Positive Tests: Gutfall (Normalfall) => "gültige ÄK/GW"
  • Negativ-Tests (Fehlbedienung, ungültige Werte) => "ungültige ÄK/GW"
  • Rand- bzw. Extremwerte => GW
  • Exceptions

=> Anforderungen abgedeckt (Black-Box)?

=> Wichtige Pfade im Code abgedeckt (White-Box)?

Praxis

  • Je kritischer eine Klasse/Methode/Artefakt ist, um so intensiver testen!
  • Suche nach Kompromissen: Testkosten vs. Kosten von Folgefehlern; beispielsweise kein Test generierter Methoden

=> "Erzeugen" der Testfälle über die Äquivalenzklassenbildung und Grenzwertanalyse (siehe nächste Folien). Mehr dann später im Wahlfach "Softwarequalität" ...

Äquivalenzklassenbildung

Beispiel: Zu testende Methode mit Eingabewert x, der zw. 10 und 100 liegen soll

  • Zerlegung der Definitionsbereiche in Äquivalenzklassen (ÄK):

    • Disjunkte Teilmengen, wobei
    • Werte einer ÄK führen zu gleichartigem Verhalten
  • Annahme: Eingabeparameter sind untereinander unabhängig

  • Unterscheidung gültige und ungültige ÄK

Bemerkungen

Hintergrund: Da die Werte einer ÄK zu gleichartigem Verhalten führen, ist es egal, welchen Wert man aus einer ÄK für den Test nimmt.

Formal hat man eine ungültige ÄK (d.h. die Menge aller ungültigen Werte). In der Programmierpraxis macht es aber einen Unterschied, ob es sich um Werte unterhalb oder oberhalb des erlaubten Wertebereichs handelt (Fallunterscheidung). Beispiel: Eine Funktion soll Werte zwischen 10 und 100 verarbeiten. Dann sind alle Werte kleiner 10 oder größer 100 mathematisch gesehen in der selben ÄK "ungültig". Praktisch macht es aber Sinn, eine ungültige ÄK für "kleiner 10" und eine weitere ungültige ÄK für "größer 100" zu betrachten ...

Traditionell betrachtet man nur die Eingabeparameter. Es kann aber Sinn machen, auch die Ausgabeseite zu berücksichtigen (ist aber u.U. nur schwierig zu realisieren).

Faustregeln bei der Bildung von ÄK

  • Falls eine Beschränkung einen Wertebereich spezifiziert: Aufteilung in eine gültige und zwei ungültige ÄK

    Beispiel: Eingabewert x soll zw. 10 und 100 liegen

    • Gültige ÄK: $[10, 100]$
    • Ungültige ÄKs: $x < 10$ und $100 < x$
  • Falls eine Beschränkung eine minimale und maximale Anzahl von Werten spezifiziert: Aufteilung in eine gültige und zwei ungültige ÄK

    Beispiel: Jeder Studi muss pro Semester an mindestens einer LV teilnehmen, maximal sind 5 LVs erlaubt.

    • Gültige ÄK: $1 \le x \le 5$
    • Ungültige ÄKs: $x = 0$ (keine Teilnahme) und $5 < x$ (mehr als 5 Kurse)
  • Falls eine Beschränkung eine Menge von Werten spezifiziert, die möglicherweise unterschiedlich behandelt werden: Für jeden Wert dieser Menge eine eigene gültige ÄK erstellen und zusätzlich insgesamt eine ungültige ÄK

    Beispiel: Das Hotel am Urlaubsort ermöglicht verschiedene Freizeitaktivitäten: Segway-fahren, Tauchen, Tennis, Golf

    • Gültige ÄKs:
      • Segway-fahren
      • Tauchen
      • Tennis
      • Golf
    • Ungültige ÄK: "alles andere"
  • Falls eine Beschränkung eine Situation spezifiziert, die zwingend erfüllt sein muss: Aufteilung in eine gültige und eine ungültige ÄK

Hinweis: Werden Werte einer ÄK vermutlich nicht gleichwertig behandelt, dann erfolgt die Aufspaltung der ÄK in kleinere ÄKs. Das ist im Grunde die analoge Überlegung zu mehreren ungültigen ÄKs.

ÄKs sollten für die weitere Arbeit einheitlich und eindeutig benannt werden. Typisches Namensschema: "gÄKn" und "uÄKn" für gültige bzw. ungültige ÄKs mit der laufenden Nummer $n$.

ÄK: Erstellung der Testfälle

  • Jede ÄK durch mindestens einen TF abdecken

  • Dabei pro Testfall

    • mehrere gültige ÄKs kombinieren, oder
    • genau eine ungültige ÄK untersuchen (restl. Werte aus gültigen ÄK auffüllen; diese gelten dann aber nicht als getestet!)

Im Prinzip muss man zur Erstellung der Testfälle (TF) eine paarweise vollständige Kombination über die ÄK bilden, d.h. jede ÄK kommt mit jeder anderen ÄK in einem TF zur Ausführung.

Erinnerung: Annahme: Eingabeparameter sind untereinander unabhängig! => Es reicht, wenn jede gültige ÄK einmal in einem TF zur Ausführung kommt. => Kombination verschiedener gültiger ÄK in einem TF.

Achtung: Dies gilt nur für die gültigen ÄK! Bei den ungültigen ÄKs dürfen diese nicht miteinander in einem TF kombiniert werden! Bei gleichzeitiger Behandlung verschiedener ungültiger ÄK bleiben u.U. Fehler unentdeckt, da sich die Wirkungen der ungültigen ÄK überlagern!

Für jeden Testfall (TF) wird aus den zu kombinierenden ÄK ein zufälliger Repräsentant ausgewählt.

ÄK: Beispiel: Eingabewert x soll zw. 10 und 100 liegen

Äquivalenzklassen

Eingabe gültige ÄK ungültige ÄK
x gÄK1: $[10, 100]$ uÄK2: $x < 10$
uÄK3: $100 < x$

Tests

Testnummer 1 2 3
geprüfte ÄK gÄK1 uÄK2 uÄK3
x 42 7 120
Erwartetes Ergebnis OK Exception Exception

Grenzwertanalyse

Beobachtung: Grenzen in Verzweigungen/Schleifen kritisch

  • Grenzen der ÄK (kleinste und größte Werte) zusätzlich testen
    • "gültige Grenzwerte" (gGW): Grenzwerte von gültigen ÄK
    • "ungültige Grenzwerte" (uGW): Grenzwerte von ungültigen ÄK

Zusätzlich sinnvoll: Weitere grenznahe Werte, d.h. weitere Werte "rechts" und "links" der Grenze nutzen.

Bildung der Testfälle:

  • Jeder GW muss in mind. einem TF vorkommen

Pro TF darf ein GW (gültig oder ungültig) verwendet werden, die restlichen Parameter werden (mit zufälligen Werten) aus gültigen ÄK aufgefüllt, um mögliche Grenzwertprobleme nicht zu überlagern.

GW: Beispiel: Eingabewert x soll zw. 10 und 100 liegen

Äquivalenzklassen

Eingabe gültige ÄK ungültige ÄK
x gÄK1: $[10, 100]$ uÄK2: $x < 10$
uÄK3: $100 < x$

Grenzwertanalyse

Zusätzliche Testdaten: 9 (uÄK2o) und 10 (gÄK1u) sowie 100 (gÄK1o) und 101 (uÄK3u)

Tests

Testnummer 4 5 6 7
geprüfter GW gÄK1u gÄK1o uÄK2o uÄK3u
x 10 100 9 101
Erwartetes Ergebnis OK OK Exception Exception

Hinweis: Die Ergebnisse der GW-Analyse werden zusätzlich zu den Werten aus der ÄK-Analyse eingesetzt. Für das obige Beispiel würde man also folgende Tests aus der kombinierten ÄK- und GW-Analyse erhalten:

Testnummer 1 2 3 4 5 6 7
geprüfte(r) ÄK/GW gÄK1 uÄK2 uÄK3 gÄK1u gÄK1o uÄK2o uÄK3u
x 42 7 120 10 100 9 101
Erwartetes Ergebnis OK Exception Exception OK OK Exception Exception

Anmerkung: Analyse abhängiger Parameter

Wenn das Ergebnis von der Kombination der Eingabewerte abhängt, dann sollte man dies bei der Äquivalenzklassenbildung berücksichtigen: Die ÄK sind in diesem Fall in Bezug auf die Kombinationen zu bilden!

Schauen Sie sich dazu das Beispiel im [Kleuker2019], Abschnitt "4.3 Analyse abhängiger Parameter" an.

Die einfache ÄK-Bildung würde in diesem Fall versagen, da die Eingabewerte nicht unabhängig sind. Leider ist die Betrachtung der möglichen Kombinationen u.U. eine sehr komplexe Aufgabe ...

Analoge Überlegungen gelten auch für die ÄK-Bildung im Zusammenhang mit objektorientierter Programmierung. Die Eingabewerte und der Objektzustand müssen dann gemeinsam bei der ÄK-Bildung betrachtet werden!

Vergleiche [Kleuker2019], Abschnitt "4.4 Äquivalenzklassen und Objektorientierung".

Wrap-Up

  • Gründliches Testen ist ebenso viel Aufwand wie Coden
  • Äquivalenzklassenbildung und Grenzwertanalyse
Challenges

ÄK/GW: RSV Flotte Speiche

Der RSV Flotte Speiche hat in seiner Mitgliederverwaltung (MitgliederVerwaltung) die Methode testBeitritt implementiert. Mit dieser Methode wird geprüft, ob neue Mitglieder in den Radsportverein aufgenommen werden können.

public class MitgliederVerwaltung {

    /**
     * Testet, ob ein Mitglied in den Verein aufgenommen werden kann.
     *
     * <p>Interessierte Personen müssen mindestens 16 Jahre alt sein, um aufgenommen
     * werden zu können. Die Motivation darf nicht zu niedrig und auch nicht zu hoch
     * sein und muss zwischen 4 und 7 (inklusive) liegen, sonst wird der Antrag
     * abgelehnt.
     *
     * <p>Der Wertebereich beim Alter umfasst die natürlichen Zahlen zwischen 0 und 99
     * (inklusive), bei der Motivation sind die natürlichen Zahlen zwischen 0 und 10
     * (inklusive) erlaubt.
     *
     * <p>Bei Verletzung der zulässigen Wertebereiche der Parameter wird eine
     * <code>IllegalArgumentException</code> geworfen.
     *
     * @param alter       Alter in Lebensjahren, Bereich [0, 99]
     * @param motivation  Motivation auf einer Scala von 0 bis 10
     * @return <code>true</code>, wenn das Mitglied aufgenommen werden kann,
     *         sonst <code>false</code>
     * @throws <code>IllegalArgumentException</code>, wenn Parameter außerhalb
     *                                                der zulässigen Wertebereiche
     */
    public boolean testBeitritt(int alter, int motivation) {
        // Implementierung versteckt
    }
}
  1. Führen Sie eine Äquivalenzklassenbildung durch und geben Sie die gefundenen Äquivalenzklassen (ÄK) an: laufende Nummer, Definition (Wertebereiche o.ä.), kurze Beschreibung (gültige/ungültige ÄK, Bedeutung).

  2. Führen Sie zusätzlich eine Grenzwertanalyse durch und geben Sie die jeweiligen Grenzwerte (GW) an.

  3. Erstellen Sie aus den ÄK und GW wie in der Vorlesung diskutiert Testfälle. Geben Sie pro Testfall (TF) an, welche ÄK und/oder GW abgedeckt sind, welche Eingaben Sie vorsehen und welche Ausgabe Sie erwarten.

    Hinweis: Erstellen Sie separate (zusätzliche) TF für die GW, d.h. integrieren Sie diese nicht in die ÄK-TF.

  4. Implementieren Sie die Testfälle in JUnit (JUnit 4 oder 5).

    • Fassen Sie die Testfälle der gültigen ÄK in einem parametrisierten Test zusammen.
    • Für die ungültigen ÄKs erstellen Sie jeweils eine eigene JUnit-Testmethode. Beachten Sie, dass Sie auch die Exceptions testen müssen.

ÄK/GW: LSF

Das LSF bestimmt mit der Methode LSF#checkStudentCPS, ob ein Studierender bereits zur Bachelorarbeit oder Praxisphase zugelassen werden kann:

class LSF {
    public static Status checkStudentCPS(Student student) {
        if (student.credits() >= Status.BACHELOR.credits) return Status.BACHELOR;
        else if (student.credits() >= Status.PRAXIS.credits) return Status.PRAXIS;
        else return Status.NONE;
    }
}

record Student(String name, int credits, int semester) { }

enum Status {
    NONE(0), PRAXIS(110), BACHELOR(190);  // min: 0, max: 210

    public final int credits;
    Status(int credits) { this.credits = credits; }
}
  1. Führen Sie eine Äquivalenzklassenbildung für die Methode LSF#checkStudentCPS durch.
  2. Führen Sie zusätzlich eine Grenzwertanalyse für die Methode LSF#checkStudentCPS durch.
  3. Erstellen Sie aus den ÄK und GW wie in der Vorlesung diskutiert Testfälle.
  4. Implementieren Sie die Testfälle in JUnit (JUnit 4 oder 5).
    • Fassen Sie die Testfälle der gültigen ÄK in einem parametrisierten Test zusammen.
    • Für die ungültigen ÄKs erstellen Sie jeweils eine eigene JUnit-Testmethode. Beachten Sie, dass Sie auch die Exceptions testen müssen.
Quellen

Mocking mit Mockito

TL;DR

Häufig hat man es in Softwaretests mit dem Problem zu tun, dass die zu testenden Klassen von anderen, noch nicht implementierten Klassen oder von zufälligen oder langsamen Operationen abhängen.

In solchen Situationen kann man auf "Platzhalter" für diese Abhängigkeiten zurückgreifen. Dies können einfache Stubs sein, also Objekte, die einfach einen festen Wert bei einem Methodenaufruf zurückliefern oder Mocks, wo man auf die Argumente eines Methodenaufrufs reagieren kann und passende unterschiedliche Rückgabewerte zurückgeben kann.

Mockito ist eine Java-Bibliothek, die zusammen mit JUnit das Mocking von Klassen in Java erlaubt. Man kann hier zusätzlich auch die Interaktion mit dem gemockten Objekt überprüfen und testen, ob eine bestimmte Methode mit bestimmten Argumenten aufgerufen wurde und wie oft.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Begriffe: Mocking, Mock, Stub, Spy
  • (K3) Erzeugen eines Mocks in Mockito
  • (K3) Erzeugen eines Spies in Mockito
  • (K3) Prüfen von Interaktion mit verify()
  • (K3) Einsatz von ArgumentMatcher

Motivation: Entwicklung einer Studi-/Prüfungsverwaltung

Szenario

Zwei Teams entwickeln eine neue Studi-/Prüfungsverwaltung für die Hochschule. Ein Team modelliert dabei die Studierenden, ein anderes Team modelliert die Prüfungsverwaltung LSF.

  • Team A:

    public class Studi {
        String name;  LSF lsf;
    
        public Studi(String name, LSF lsf) {
            this.name = name;  this.lsf = lsf;
        }
    
        public boolean anmelden(String modul) { return lsf.anmelden(name, modul); }
        public boolean einsicht(String modul) { return lsf.ergebnis(name, modul) > 50; }
    }
  • Team B:

    public class LSF {
        public boolean anmelden(String name, String modul) { throw new UnsupportedOperationException(); }
        public int ergebnis(String name, String modul) { throw new UnsupportedOperationException(); }
    }

Team B kommt nicht so recht vorwärts, Team A ist fertig und will schon testen.

Wie kann Team A seinen Code testen?

Optionen:

  • Gar nicht testen?!
  • Das LSF selbst implementieren? Wer pflegt das dann? => manuell implementierte Stubs
  • Das LSF durch einen Mock ersetzen => Einsatz der Bibliothek "mockito"

Motivation Mocking und Mockito

Mockito ist ein Mocking-Framework für JUnit. Es simuliert das Verhalten eines realen Objektes oder einer realen Methode.

Wofür brauchen wir denn jetzt so ein Mocking-Framework überhaupt?

Wir wollen die Funktionalität einer Klasse isoliert vom Rest testen können. Dabei stören uns aber bisher so ein paar Dinge:

  • Arbeiten mit den echten Objekten ist langsam (zum Beispiel aufgrund von Datenbankenzugriffen)
  • Objekte beinhalten oft komplexe Abhängigkeiten, die in Tests schwer abzudecken sind
  • Manchmal existiert der zu testende Teil einer Applikation auch noch gar nicht, sondern es gibt nur die Interfaces.
  • Oder es gibt unschöne Seiteneffekte beim Arbeiten mit den realen Objekten. Zum Beispiel könnte es sein, das immer eine E-Mail versendet wird, wenn wir mit einem Objekt interagieren.

In solchen Situationen wollen wir eine Möglichkeit haben, das Verhalten eines realen Objektes bzw. der Methoden zu simulieren, ohne dabei die originalen Methoden aufrufen zu müssen. (Manchmal möchte man das dennoch, aber dazu später mehr...)

Und genau hier kommt Mockito ins Spiel. Mockito hilft uns dabei, uns von den externen Abhängigkeiten zu lösen, indem es sogenannte Mocks, Stubs oder Spies anbietet, mit denen sich das Verhalten der realen Objekte simulieren/überwachen und testen lässt.

Aber was genau ist denn jetzt eigentlich Mocking?

Ein Mock-Objekt ("etwas vortäuschen") ist im Software-Test ein Objekt, das als Platzhalter (Attrappe) für das echte Objekt verwendet wird.

Mocks sind in JUnit-Tests immer dann nützlich, wenn man externe Abhängigkeiten hat, auf die der eigene Code zugreift. Das können zum Beispiel externe APIs sein oder Datenbanken etc. ... Mocks helfen einem beim Testen nun dabei, sich von diesen externen Abhängigkeiten zu lösen und seine Softwarefunktionalität dennoch schnell und effizient testen zu können ohne evtl. auftretende Verbindungsfehler oder andere mögliche Seiteneffekte der externen Abhängigkeiten auszulösen.

Dabei simulieren Mocks die Funktionalität der externen APIs oder Datenbankzugriffe. Auf diese Weise ist es möglich Softwaretests zu schreiben, die scheinbar die gleichen Methoden aufrufen, die sie auch im regulären Softwarebetrieb nutzen würden, allerdings werden diese wie oben erwähnt allerdings für die Tests nur simuliert.

Mocking ist also eine Technik, die in Softwaretests verwendet wird, in denen die gemockten Objekte anstatt der realen Objekte zu Testzwecken genutzt werden. Die gemockten Objekte liefern dabei bei einem vom Programmierer bestimmten (Dummy-) Input, einen dazu passenden gelieferten (Dummy-) Output, der durch seine vorhersagbare Funktionalität dann in den eigentlichen Testobjekten gut für den Test nutzbar ist.

Dabei ist es von Vorteil die drei Grundbegriffe "Mock", "Stub" oder "Spy", auf die wir in der Vorlesung noch häufiger treffen werden, voneinander abgrenzen und unterscheiden zu können.

Dabei bezeichnet ein

  • Stub: Ein Stub ist ein Objekt, dessen Methoden nur mit einer minimalen Logik für den Test implementiert wurden. Häufig werden dabei einfach feste (konstante) Werte zurückgeliefert, d.h. beim Aufruf einer Methode wird unabhängig von der konkreten Eingabe immer die selbe Ausgabe zurückgeliefert.
  • Mock: Ein Mock ist ein Objekt, welches im Gegensatz zum Stub bei vorher definierten Funktionsaufrufen mit vorher definierten Argumente eine definierte Rückgabe liefert.
  • Spy: Ein Spy ist ein Objekt, welches Aufrufe und übergebene Werte protokolliert und abfragbar macht. Es ist also eine Art Wrapper um einen Stub oder einen Mock.

Mockito Setup

  • Gradle: build.gradle

    dependencies {
        implementation 'junit:junit:4.13.2'
        implementation 'org.mockito:mockito-core:4.5.1'
    }
  • Maven: pom.xml

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
        </dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>4.5.1</version>
        </dependency>
    </dependencies>

Manuell Stubs implementieren

Team A könnte manuell das LSF rudimentär implementieren (nur für die Tests, einfach mit festen Rückgabewerten): Stubs

public class StudiStubTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = new LsfStub();  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() { assertTrue(studi.anmelden("PM-Dungeon")); }
    @Test
    public void testEinsicht() { assertTrue(studi.einsicht("PM-Dungeon")); }


    // Stub für das noch nicht fertige LSF
    class LsfStub extends LSF {
        public boolean anmelden(String name, String modul) { return true; }
        public int ergebnis(String name, String modul) { return 80; }
    }
}

Problem: Wartung der Tests (wenn das richtige LSF fertig ist) und Wartung der Stubs (wenn sich die Schnittstelle des LSF ändert, muss auch der Stub nachgezogen werden).

Problem: Der Stub hat nur eine Art minimale Default-Logik (sonst könnte man ja das LSF gleich selbst implementieren). Wenn man im Test andere Antworten braucht, müsste man einen weiteren Stub anlegen ...

Mockito: Mocking von ganzen Klassen

Lösung: Mocking der Klasse LSF mit Mockito für den Test von Studi: mock().

public class StudiMockTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = mock(LSF.class);  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() {
        when(lsf.anmelden(anyString(), anyString())).thenReturn(true);
        assertTrue(studi.anmelden("PM-Dungeon"));
    }

    @Test
    public void testEinsichtI() {
        when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(80);
        assertTrue(studi.einsicht("PM-Dungeon"));
    }
    @Test
    public void testEinsichtII() {
        when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(40);
        assertFalse(studi.einsicht("PM-Dungeon"));
    }
}

Der Aufruf mock(LSF.class) erzeugt einen Mock der Klasse (oder des Interfaces) LSF. Dabei wird ein Objekt vom Typ LSF erzeugt, mit dem man dann wie mit einem normalen Objekt weiter arbeiten kann. Die Methoden sind allerdings nicht implementiert ...

Mit Hilfe von when().thenReturn() kann man definieren, was genau beim Aufruf einer bestimmten Methode auf dem Mock passieren soll, d.h. welcher Rückgabewert entsprechend zurückgegeben werden soll. Hier kann man dann für bestimmte Argumentwerte andere Rückgabewerte definieren. when(lsf.ergebnis("Harald", "PM-Dungeon")).thenReturn(80) gibt also für den Aufruf von ergebnis mit den Argumenten "Harald" und "PM-Dungeon" auf dem Mock lsf den Wert 80 zurück.

Dies kann man in weiten Grenzen flexibel anpassen.

Mit Hilfe der Argument-Matcher anyString() wird jedes String-Argument akzeptiert.

Mockito: Spy = Wrapper um ein Objekt

Team B hat das LSF nun implementiert und Team A kann es endlich für die Tests benutzen. Aber das LSF hat eine Zufallskomponente (ergebnis()). Wie kann man nun die Reaktion des Studis testen (einsicht())?

Lösung: Mockito-Spy als partieller Mock einer Klasse (Wrapper um ein Objekt): spy().

public class StudiSpyTest {
    Studi studi;  LSF lsf;

    @Before
    public void setUp() { lsf = spy(LSF.class);  studi = new Studi("Harald", lsf); }

    @Test
    public void testAnmelden() { assertTrue(studi.anmelden("PM-Dungeon")); }

    @Test
    public void testEinsichtI() {
        doReturn(80).when(lsf).ergebnis("Harald", "PM-Dungeon");
        assertTrue(studi.einsicht("PM-Dungeon"));
    }
    @Test
    public void testEinsichtII() {
        doReturn(40).when(lsf).ergebnis("Harald", "PM-Dungeon");
        assertFalse(studi.einsicht("PM-Dungeon"));
    }
}

Der Aufruf spy(LSF.class) erzeugt einen Spy um ein Objekt der Klasse LSF. Dabei bleiben zunächst die Methoden in LSF erhalten und können aufgerufen werden, sie können aber auch mit einem (partiellen) Mock überlagert werden. Der Spy zeichnet wie der Mock die Interaktion mit dem Objekt auf.

Mit Hilfe von doReturn().when() kann man definieren, was genau beim Aufruf einer bestimmten Methode auf dem Spy passieren soll, d.h. welcher Rückgabewert entsprechend zurückgegeben werden soll. Hier kann man analog zum Mock für bestimmte Argumentwerte andere Rückgabewerte definieren. doReturn(40).when(lsf).ergebnis("Harald", "PM-Dungeon") gibt also für den Aufruf von ergebnis mit den Argumenten "Harald" und "PM-Dungeon" auf dem Spy lsf den Wert 40 zurück.

Wenn man die Methoden nicht mit einem partiellen Mock überschreibt, dann wird einfach die originale Methode aufgerufen (Beispiel: In studi.anmelden("PM-Dungeon") wird lsf.anmelden("Harald", "PM-Dungeon") aufgerufen.).

Auch hier können Argument-Matcher wie anyString() eingesetzt werden.

Wurde eine Methode aufgerufen?

public class VerifyTest {
    @Test
    public void testAnmelden() {
        LSF lsf = mock(LSF.class);  Studi studi = new Studi("Harald", lsf);

        when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

        assertTrue(studi.anmelden("PM-Dungeon"));


        verify(lsf).anmelden("Harald", "PM-Dungeon");
        verify(lsf, times(1)).anmelden("Harald", "PM-Dungeon");

        verify(lsf, atLeast(1)).anmelden("Harald", "PM-Dungeon");
        verify(lsf, atMost(1)).anmelden("Harald", "PM-Dungeon");

        verify(lsf, never()).ergebnis("Harald", "PM-Dungeon");

        verifyNoMoreInteractions(lsf);
    }
}

Mit der Methode verify() kann auf einem Mock oder Spy überprüft werden, ob und wie oft und in welcher Reihenfolge Methoden aufgerufen wurden und mit welchen Argumenten. Auch hier lassen sich wieder Argument-Matcher wie anyString() einsetzen.

Ein einfaches verify(mock) prüft dabei, ob die entsprechende Methode exakt einmal vorher aufgerufen wurde. Dies ist äquivalent zu verify(mock, times(1)). Analog kann man mit den Parametern atLeast() oder atMost bestimmte Unter- oder Obergrenzen für die Aufrufe angeben und mit never() prüfen, ob es gar keinen Aufruf vorher gab.

verifyNoMoreInteractions(lsf) ist interessant: Es ist genau dann true, wenn es außer den vorher abgefragten Interaktionen keinerlei sonstigen Interaktionen mit dem Mock oder Spy gab.

LSF lsf = mock(LSF.class);
Studi studi = new Studi("Harald", lsf);

when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

InOrder inOrder = inOrder(lsf);

assertTrue(studi.anmelden("PM-Dungeon"));
studi.anmelden("Wuppie");

inOrder.verify(lsf).anmelden("Harald", "Wuppie");
inOrder.verify(lsf).anmelden("Harald", "PM-Dungeon");

Mit InOrder lassen sich Aufrufe auf einem Mock/Spy oder auch auf verschiedenen Mocks/Spies in eine zeitliche Reihenfolge bringen und so überprüfen.

Fangen von Argumenten

public class MatcherTest {
    @Test
    public void testAnmelden() {
        LSF lsf = mock(LSF.class);  Studi studi = new Studi("Harald", lsf);

        when(lsf.anmelden(anyString(), anyString())).thenReturn(false);
        when(lsf.anmelden("Harald", "PM-Dungeon")).thenReturn(true);

        assertTrue(studi.anmelden("PM-Dungeon"));
        assertFalse(studi.anmelden("Wuppie?"));

        verify(lsf, times(1)).anmelden("Harald", "PM-Dungeon");
        verify(lsf, times(1)).anmelden("Harald", "Wuppie?");

        verify(lsf, times(2)).anmelden(anyString(), anyString());
        verify(lsf, times(1)).anmelden(eq("Harald"), eq("Wuppie?"));
        verify(lsf, times(2)).anmelden(argThat(new MyHaraldMatcher()), anyString());
    }


    class MyHaraldMatcher implements ArgumentMatcher<String> {
        public boolean matches(String s) { return s.equals("Harald"); }
    }
}

Sie können die konkreten Argumente angeben, für die der Aufruf gelten soll. Alternativ können Sie mit vordefinierten ArgumentMatchers wie anyString() beispielsweise auf beliebige Strings reagieren oder selbst einen eigenen ArgumentMatcher<T> für Ihren Typ T erstellen und nutzen.

Wichtig: Wenn Sie für einen Parameter einen ArgumentMatcher einsetzen, müssen Sie für die restlichen Parameter der Methode dies ebenfalls tun. Sie können keine konkreten Argumente mit ArgumentMatcher mischen.

Sie finden viele weitere vordefinierte Matcher in der Klasse ArgumentMatchers. Mit der Klasse ArgumentCaptor<T> finden Sie eine alternative Möglichkeit, auf Argumente in gemockten Methoden zu reagieren. Schauen Sie sich dazu die Javadoc von Mockito an.

Ausblick: PowerMock

Mockito sehr mächtig, aber unterstützt (u.a.) keine

  • Konstruktoren
  • private Methoden
  • final Methoden
  • static Methoden (ab Version 3.4.0 scheint auch Mockito statische Methoden zu unterstützen)

=> Lösung: PowerMock

Ausführlicheres Beispiel: WuppiWarenlager

Credits: Der Dank für die Erstellung des nachfolgenden Beispiels und Textes geht an @jedi101.

Bei dem gezeigten Beispiel unseres WuppiStores sieht man, dass dieser normalerweise von einem fertigen Warenlager die Wuppis beziehen möchte. Da dieses Lager aber noch nicht existiert, haben wir uns kurzerhand einfach einen Stub von unserem IWuppiWarenlager-Interface erstellt, in dem wir zu Testzwecken händisch ein Paar Wuppis ins Lager geräumt haben.

Das funktioniert in diesem Mini-Testbeispiel ganz gut aber, wenn unsere Stores erst einmal so richtig Fahrt aufnehmen und wir irgendwann weltweit Wuppis verkaufen, wird der Code des IWuppiWarenlagers wahrscheinlich sehr schnell viel komplexer werden, was unweigerlich dann zu Maintenance-Problemen unserer händisch angelegten Tests führt. Wenn wir zum Beispiel einmal eine Methode hinzufügen wollen, die es uns ermöglicht, nicht immer alle Wuppis aus dem Lager zu ordern oder vielleicht noch andere Methoden, die Fluppis orderbar machen, hinzufügen, müssen wir immer dafür sorgen, dass wir die getätigten Änderungen händisch in den Stub des Warenlagers einpflegen.

Das will eigentlich niemand...

Einsatz von Mockito

Aber es gibt da einen Ausweg. Wenn es komplexer wird, verwenden wir Mocks.

Bislang haben wir noch keinen Gebrauch von Mockito gemacht. Das ändern wir nun.

Wie in diesem Beispiel gezeigt, müssen wir nun keinen Stub mehr von Hand erstellen, sondern überlassen dies Mockito.

IWuppiWarenlager lager = mock(IWuppiWarenlager.class);

Anschließend können wir, ohne die Methode getAllWuppis() implementiert zu haben, dennoch so tun als, ob die Methode eine Funktionalität hätte.

// Erstellen eines imaginären Lagerbestands.
List<String> wuppisImLager = Arrays.asList("GruenerWuppi","RoterWuppi");
when(lager.getAlleWuppis()).thenReturn(wuppisImLager);

Wann immer nun die Methode getAlleWuppis() des gemockten Lagers aufgerufen wird, wird dieser Aufruf von Mockito abgefangen und wie oben definiert verändert. Das Ergebnis können wir abschließend einfach in unserem Test testen:

// Erzeugen des WuppiStores.
WuppiStore wuppiStore = new WuppiStore(lager);

// Bestelle alle Wuppis aus dem gemockten Lager List<String>
bestellteWuppis = wuppiStore.bestelleAlleWuppis(lager);

// Hat die Bestellung geklappt?
assertEquals(2,bestellteWuppis.size());

Mockito Spies

Manchmal möchten wir allerdings nicht immer gleich ein ganzes Objekt mocken, aber dennoch Einfluss auf die aufgerufenen Methoden eines Objekts haben, um diese testen zu können. Vielleicht gibt es dabei ja sogar eine Möglichkeit unsere JUnit-Tests, mit denen wir normalerweise nur Rückgabewerte von Methoden testen können, zusätzlich auch das Verhalten also die Interaktionen mit einem Objekt beobachtbar zu machen. Somit wären diese Interaktionen auch testbar.

Und genau dafür bietet Mockito eine Funktion: der sogenannte "Spy".

Dieser Spion erlaubt es uns nun zusätzlich das Verhalten zu testen. Das geht in die Richtung von BDD - Behavior Driven Development.

// Spion erstellen, der unser wuppiWarenlager überwacht.
this.wuppiWarenlager = spy(WuppiWarenlager.class);

Hier hatten wir uns einen Spion erzeugt, mit dem sich anschließend das Verhalten verändern lässt:

when(wuppiWarenlager.getAlleWuppis()).thenReturn(Arrays.asList(new Wuppi("Wuppi007")));

Aber auch der Zugriff lässt sich kontrollieren/testen:

verify(wuppiWarenlager).addWuppi(normalerWuppi);
verifyNoMoreInteractions(wuppiWarenlager);

Die normalen Testmöglichkeiten von JUnit runden unseren Test zudem ab.

assertEquals(1,wuppiWarenlager.lager.size());

Mockito und Annotationen

In Mockito können Sie wie oben gezeigt mit mock() und spy() neue Mocks bzw. Spies erzeugen und mit verify() die Interaktion überprüfen und mit ArgumentMatcher<T> bzw. den vordefinierten ArgumentMatchers auf Argumente zuzugreifen bzw. darauf zu reagieren.

Zusätzlich/alternativ gibt es in Mockito zahlreiche Annotationen, die ersatzweise statt der genannten Methoden genutzt werden können. Hier ein kleiner Überblick über die wichtigsten in Mockito verwendeten Annotation:

  • @Mock wird zum Markieren des zu mockenden Objekts verwendet.

    @Mock
    WuppiWarenlager lager;
  • @RunWith(MockitoJUnitRunner.class) ist der entsprechende JUnit-Runner, wenn Sie Mocks mit @Mock anlegen.

    @RunWith(MockitoJUnitRunner.class)
    public class ToDoBusinessMock {...}
  • @Spy erlaubt das Erstellen von partiell gemockten Objekten. Dabei wird eine Art Wrapper um das zu mockende Objekt gewickelt, der dafür sorgt, dass alle Methodenaufrufe des Objekts an den Spy delegiert werden. Diese können über den Spion dann abgefangen/verändert oder ausgewertet werden.

    @Spy
    ArrayList<Wuppi> arrayListenSpion;
  • @InjectMocks erlaubt es, Parameter zu markieren, in denen Mocks und/oder Spies injiziert werden. Mockito versucht dann (in dieser Reihenfolge) per Konstruktorinjektion, Setterinjektion oder Propertyinjektion die Mocks zu injizieren. Weitere Informationen darüber findet man hier: Mockito Dokumentation

    Anmerkung: Es ist aber nicht ratsam "Field- oder Setterinjection" zu nutzen, da man nur bei der Verwendung von "Constructorinjection" sicherstellen kann, das eine Klasse nicht ohne die eigentlich notwendigen Parameter instanziiert wurde.

    @InjectMocks
    Wuppi fluppi;
  • @Captor erlaubt es, die Argumente einer Methode abzufangen/auszuwerten. Im Zusammenspiel mit Mockitos verify()-Methode kann man somit auch die einer Methode übergebenen Argumente verifizieren.

    @Captor
    ArgumentCaptor<String> argumentCaptor;
  • @ExtendWith(MockitoExtension.class) wird in JUnit5 verwendet, um die Initialisierung von Mocks zu vereinfachen. Damit entfällt zum Beispiel die noch unter JUnit4 nötige Initialisierung der Mocks durch einen Aufruf der Methode MockitoAnnotations.openMocks() im Setup des Tests (@Before bzw. @BeforeEach).

Prüfen der Interaktion mit verify()

Mit Hilfe der umfangreichen verify()-Methoden, die uns Mockito mitliefert, können wir unseren Code unter anderem auf unerwünschte Seiteneffekte testen. So ist es mit verify zum Beispiel möglich abzufragen, ob mit einem gemockten Objekt interagiert wurde, wie damit interagiert wurde, welche Argumente dabei übergeben worden sind und in welcher Reihenfolge die Interaktionen damit erfolgt sind.

Hier nur eine kurze Übersicht über das Testen des Codes mit Hilfe von Mockitos verify()-Methoden.

@Test
public void testVerify_DasKeineInteraktionMitDerListeStattgefundenHat() {
    // Testet, ob die spezifizierte Interaktion mit der Liste nie stattgefunden hat.
    verify(fluppisListe, never()).clear();
}
@Test
public void testVerify_ReihenfolgeDerInteraktionenMitDerFluppisListe() {
    // Testet, ob die Reihenfolge der spezifizierten Interaktionen mit der Liste eingehalten wurde.
    fluppisListe.clear();
    InOrder reihenfolge = inOrder(fluppisListe);
    reihenfolge.verify(fluppisListe).add("Fluppi001");
    reihenfolge.verify(fluppisListe).clear();
}
@Test
public void testVerify_FlexibleArgumenteBeimZugriffAufFluppisListe() {
    // Testet, ob schon jemals etwas zu der Liste hinzugefügt wurde.
    // Dabei ist es egal welcher String eingegeben wurde.
    verify(fluppisListe).add(anyString());
}
@Test
public void testVerify_InteraktionenMitHilfeDesArgumentCaptor() {
    // Testet, welches Argument beim Methodenaufruf übergeben wurde.
    fluppisListe.addAll(Arrays.asList("BobDerBaumeister"));
    ArgumentCaptor<List> argumentMagnet = ArgumentCaptor.forClass(FluppisListe.class);
    verify(fluppisListe).addAll(argumentMagnet.capture());
    List<String> argumente = argumentMagnet.getValue();
    assertEquals("BobDerBaumeister", argumente.get(0));
}

Wrap-Up

  • Gründliches Testen ist ebenso viel Aufwand wie Coden!

  • Mockito ergänzt JUnit:

    • Mocken ganzer Klassen (mock(), when().thenReturn())
    • Wrappen von Objekten (spy(), doReturn().when())
    • Auswerten, wie häufig Methoden aufgerufen wurden (verify())
    • Auswerten, mit welchen Argumenten Methoden aufgerufen wurden (anyString)
Challenges

Betrachten Sie die drei Klassen Utility.java, Evil.java und UtilityTest.java:

public class Utility {
    private int intResult = 0;
    private Evil evilClass;

    public Utility(Evil evilClass) {
        this.evilClass = evilClass;
    }

    public void evilMethod() {
        int i = 2 / 0;
    }

    public int nonEvilAdd(int a, int b) {
        return a + b;
    }

    public int evilAdd(int a, int b) {
        evilClass.evilMethod();
        return a + b;
    }

    public void veryEvilAdd(int a, int b) {
        evilMethod();
        evilClass.evilMethod();
        intResult = a + b;
    }

    public int getIntResult() {
        return intResult;
    }
}

public class Evil {
    public void evilMethod() {
        int i = 3 / 0;
    }
}

public class UtilityTest {
    private Utility utilityClass;
    // Initialisieren Sie die Attribute entsprechend vor jedem Test.

    @Test
    void test_nonEvilAdd() {
        Assertions.assertEquals(10, utilityClass.nonEvilAdd(9, 1));
    }

    @Test
    void test_evilAdd() {
        Assertions.assertEquals(10, utilityClass.evilAdd(9, 1));
    }

    @Test
    void test_veryEvilAdd() {
        utilityClass.veryEvilAdd(9, 1);
        Assertions.assertEquals(10, utilityClass.getIntResult());
    }
}

Testen Sie die Methoden nonEvilAdd, evilAdd und veryEvilAdd der Klasse Utility.java mit dem JUnit- und dem Mockito-Framework.

Vervollständigen Sie dazu die Klasse UtilityTest.java und nutzen Sie Mocking mit Mockito, um die Tests zum Laufen zu bringen. Die Tests dürfen Sie entsprechend verändern, aber die Aufrufe aus der Vorgabe müssen erhalten bleiben. Die Klassen Evil.java und Utility.java dürfen Sie nicht ändern.

Hinweis: Die Klasse Evil.java und die Methode evilMethod() aus Utility.java lösen eine ungewollte bzw. "zufällige" Exception aus, auf deren Auftreten jedoch nicht getestet werden soll. Stattdessen sollen diese Klassen bzw. Methoden mit Mockito "weggemockt" werden, so dass die vorgegebenen Testmethoden (wieder) funktionieren.

Quellen
  • [Mockito] Mockito
    S. Faber and B. Dutheil and R. Winterhalter and T.v.d. Lippe, 2022.