Javaland und Unit-Tests zum Wegschmeißen

Wir von der Flavia fahren regelmäßig mit mehreren Kollegen zur Javaland. Vom letzten Javaland ist mir einer der Vorträge besonders in Erinnerung geblieben, weil mich das Thema angeregt hat, über die Tests in meinem Projekt nachzudenken. Der Vortrag namens „Löscht Eure Unit-Tests“ wurde von Arne Limburg gehalten und war sehr gut besucht, was sicherlich an der Mischung aus originellem Titel und wichtigem Thema lag. Viele Entwickler sind bestrebt, sich beim Thema Testen zu verbessern. Einer der Gründe ist meiner Meinung nach, dass wir die Zeit, die wir für die Tests brauchen, leider nicht mehr für neue Features zur Verfügung haben. Die Test selber sollen uns allerdings die nötige Sicherheit geben, dass unsere Software so funktioniert, wie wir es geplant haben. Aus dem Grund ist es besonders wichtig bei den Tests so effektiv wie möglich zu sein.

An dieser Stelle kommen wir auch schon zum Kern des Vortrags. Anders als der Titel mich hat vermuten lassen, sollen nicht alle Unit-Tests gelöscht werden, sondern nur diejenigen, die uns im Laufe des Projektes mehr Zeit und Sicherheit kosten als sie nützen. Man solle folglich die Anzahl der Unit-Tests reduzieren und sie durch Integration Tests ersetzen. Bildlich gesprochen: von der klassischen Test Pyramide hin zu einer Test Raute mit wenig Unit-Tests, vielen Integrations-Tests und wenig E2E-Tests.

Um zu verstehen warum wir aktuell nach der Test Pyramide testen und nicht nach einer Test Raute schauen wir uns einmal die verschieden Arten der Tests an und deren Vor- und Nachteile.

Die Unit Tests

Bei dem Unit Test werden kleine einzelne Teile der Software getestet und alle Abhängigkeiten werden nur so weit wie nötig bedient. Hier werden oft die Abhängigkeiten simuliert. Als analoges Beispiel nehmen wir ein Auto, das getestet werden soll. Davon sucht man sich eine kleine abgeschlossene Einheit aus. Also zum Beispiel die Benzinpumpe. Bei der Benzinpumpe haben wir zwei Abhängigkeiten, die wir simulieren müssen. Als erstes brauchen wir eine Flüssigkeit die von der Pumpe angezogen werden soll am Eingang. Als zweites brauchen wir ein Steuersignal, das der Pumpe sagt, wie schnell und viel gepumpt werden soll. Als erwartetes Ergebnis haben wir dann eine bestimmte Menge an Flüssigkeit, die nach dem Test gepumpt wurde. Zudem kann man auch noch Randbedingungen testen, die erfüllt werden sollen. Dazu gehört z.B., dass die Pumpe dicht sein soll.

Beispiel

Wir wollen folgenden Service testen:

@Service
public class GaspumpService {

    private static final int GAS_CONSUME_AT_FULL_THROTTLE_IN_ML = 500;
    private static final int PERCENT  = 100;

    private final PedalService pedalService;
    private final GastankService gastankService;

    public GaspumpService(PedalService pedalService, GastankService benzinTankService) {
        this.pedalService = pedalService;
        this.gastankService = benzinTankService;
    }

    public int pumpInterval() throws TankEmptyException {
        int pumpedGasoline = pedalService.getPedalPosition() * GAS_CONSUME_AT_FULL_THROTTLE_IN_ML / PERCENT;
        return gastankService.getGasolineFromTank(pumpedGasoline);
    }

    public int refuel(int gasolineInMl){
        return gastankService.refuel(gasolineInMl);
    }
    public void emptyTank(){
        gastankService.emptyTank();
    }

}

Dann könnte ein Unit-Test wie folgt aussehen:

@ExtendWith(MockitoExtension.class)
class GaspumpServiceTest {
    @Mock
    PedalService pedalService;
    @Mock
    GastankService gasTankService;

    GaspumpService gaspumpService;

    @BeforeEach
    void init() {
        gaspumpService = new GaspumpService(pedalService, gasTankService);
    }

    private static final int HALF_THROTTLE = 50;
    private static int GASOLINE_250_ML = 250;

    @Test
    void testGasPump() throws TankEmptyException {
        Mockito.when(pedalService.getPedalPosition()).thenReturn(HALF_THROTTLE);
        Mockito.when(gasTankService.getGasolineFromTank(GASOLINE_250_ML)).thenReturn(GASOLINE_250_ML);

        assertEquals(GASOLINE_250_ML, gaspumpService.pumpInterval());
    }

    @Test
    void testTankEmptyException() throws TankEmptyException {
        Mockito.when(pedalService.getPedalPosition()).thenReturn(HALF_THROTTLE);
        Mockito.when(gasTankService.getGasolineFromTank(GASOLINE_250_ML)).thenThrow(TankEmptyException.class);

        assertThrows(TankEmptyException.class, () -> gaspumpService.pumpInterval());
    }
}

Nun können wir sehen, dass der PedalService und der GastankService als Mock in den GaspumpService gegeben werden und jeder Aufruf wird über ein Mockito.when.thenReturn einzeln gemockt.

Die Idee hinter den Unit-Tests ist es, dass, wenn alle kleinen Einheiten einer Software einzeln getestet werden und richtig zusammen gebaut werden, die ganze Software in Ordnung ist. Analog: Wenn wir alle Einzelteile vom Auto Testen und es dann richtig zusammenbauen, funktioniert das ganze Auto.

Der größte Vorteil von den Unit-Tests sind, dass sie in der Regel sehr schnell laufen und auch recht schnell entwickelt werden können. Der Nachteil ist, dass die Abhängigkeiten nur simuliert werden und dieses beim Zusammenspiel der fertigen Software eine mögliche Fehlerquelle ist, die nicht abgedeckt wird.

Die Integration Tests

Bei den Integration Tests wird die gesamte Software getestet, die schon komplett zusammengebaut und mit ihren lokalen Abhängigkeiten, wie beispielsweise Datenbanken, versorgt ist. Auch dies kann man mit dem Auto vergleichen. Hier wird das zusammengebaute Auto getestet, das auf einem Prüfstand steht und auch mit richtigen Benzin im Tank versorgt ist.

Beispiel

Hier ein Beispiel für einen Integration Test für den gleichen GaspumpService

@SpringBootTest
class GaspumpServiceITest {

    @Autowired
    private GaspumpService benzinPumpeService;

    private static int GASOLINE_500_ML = 500;
    private static int GASOLINE_250_ML = 250;


    @Test
    void testGasPump() throws TankEmptyException {
        benzinPumpeService.emptyTank();
        benzinPumpeService.refuel(GASOLINE_500_ML);
        assertEquals(GASOLINE_250_ML, benzinPumpeService.pumpInterval());
    }

    @Test()
    void testTankEmptyException() throws TankEmptyException {
        benzinPumpeService.emptyTank();
        benzinPumpeService.refuel(GASOLINE_500_ML);
        assertEquals(GASOLINE_250_ML, benzinPumpeService.pumpInterval());
        assertEquals(GASOLINE_250_ML, benzinPumpeService.pumpInterval());

        assertThrows(TankEmptyException.class, () -> {
            assertEquals(GASOLINE_250_ML, benzinPumpeService.pumpInterval());
        });
    }
}

In diesem Test überlassen wir das Auflösen der Abhängigkeiten dem Spring Framework.

Der große Vorteil ist, das diese Art der Tests deutlich aussagekräftiger sind, weil alle Teile bereits zusammen integriert worden sind und als komplette Einheit laufen. Der Nachteil ist, das diese Tests länger laufen. Zudem ist es häufig schwieriger, alle Datenkonstellationen für seine gewünschten Tests entweder über die Datenbank oder über eine Parametrisierung dem Test mitzugeben.

Die E2E-Tests

Die End-to-End-Tests sind Tests, bei denen die Software mit allen externen Abhängigkeiten versorgt wird. Hier werden auch reale externe Services verwendet. Bei unserem Beispiel mit dem Auto wäre das eine reale Testfahrt in dem Auto auf einer öffentlichen Straße.

Der Vorteil ist klar, das hier die meisten Fehlerquellen getestet werden. Die Nachteile sind allerdings nicht zu unterschätzen. Die Tests laufen noch langsamer als die Integration Tests. Außerdem ist man durch die Anbindung der externen Services auf deren Verfügbarkeit angewiesen. So führt ein Ausfall eines externen Services zu einem Scheitern eines Tests, obwohl bei der Software per se alles in Ordnung ist.

Das Problem der Unit Tests

Jetzt wo wir die Vor- und Nachteile der verschiedenen Tests kennen, ist schnell klar, warum wir die Test Pyramide bevorzugen. Bei dieser verfügen wir über viele schnelle und günstige Tests, einige Tests, die leicht teuerer und langsamer sind und wenige Tests, die langsam und teuer sind. Warum sollte man weniger von den schnellen günstigen Tests machen und stattdessen mehr von den mittleren? Der Grund dafür sind die anstehenden Simulationen. Dies ist sehr fehleranfällig, wenn sich etwas an den Abhängigkeiten ändern sollte. Wenn man z.B. bei der Benzinpumpe den Anschluss für das Steuersignal ändert, muss man an seiner Testvorrichtung erst einen neuen Stecker anbringen, bevor der Test wieder aussagekräftig ist. Das heißt: bei vielen kleinen Änderungen muss man immer wieder einige der kleinen Tests ändern, damit sie wieder erfolgreich laufen. Ein Test, der scheitert, obwohl der getestet Code eigentlich in Ordnung ist, nennt man False-Negative. Es gibt zwei entscheidende Probleme mit False-Negative Tests. Erstens haben die Entwickler einen manuellen Aufwand, diesen Test zu beheben. Zweitens haben wir, wenn wir häufiger solche False-Negativs haben, weniger Vertrauen in unsere Tests. Die Entwickler sind dann schnell versucht, die Tests zu deaktivieren, was echte Fehler verschleiern kann. Wenn man einen großen technischen Umbau im System hat, werden sehr viele von den Unit-Tests scheitern. Zu einer Zeit, zu der man eigentlich eine zuverlässige Testabdeckung braucht, um sicher zu gehen, dass alles noch so funktioniert wie vorher, ist das ein großes Problem. Genau dieses Problem hatten wir etwas zum Zeitpunkt des Vortrags auch in meinem aktuellen Projekt. Ursache dafür waren False-Negatives. Wie es zu so einem False-Negative kommt, schauen wir uns im folgenden Beispiel an.

Beispiel

Wir nehmen das oben benannte Beispiel. Die Benzinpumpe bekommt jetzt nicht mehr die Gaspedal Position von dem PedalService, sondern die gleichen Informationen von einem ControlUnitService. Der Integration Test wird weiter ohne Probleme funktionieren.

@Service
public class GaspumpService {

    private static final int GAS_CONSUME_AT_FULL_THROTTLE_IN_ML = 500;
    private static final int PERCENT  = 100;

    private final ControlUnitService controlUnitService;
    private final GastankService gastankService;

    public GaspumpService(ControlUnitService controlUnitService, GastankService benzinTankService) {
        this.controlUnitService = controlUnitService;
        this.gastankService = benzinTankService;
    }

    public int pumpInterval() throws TankEmptyException {
        int gewuenschteMengeBenzin = controlUnitService.getPedalPosition() * GAS_CONSUME_AT_FULL_THROTTLE_IN_ML / PERCENT;
        return gastankService.getGasolineFromTank(gewuenschteMengeBenzin);
    }

    public int refuel(int gasolineInMl){
        return gastankService.refuel(gasolineInMl);
    }
    public void emptyTank(){
        gastankService.emptyTank();
    }
}

Der Unit Test wird hingegen auf mehrere Fehler stoßen. Hier müssen wir den Mock vom PedalService ausbauen und einen Mock Für den ControlUnitService einbauen.

/home/xxx/IdeaProjects/demo/src/test/java/de/flavia/demo/services/GaspumpServiceTest.java:25: Fehler: Inkompatible Typen: PedalService kann nicht in ControlUnitService konvertiert werden
        gaspumpService = new GaspumpService(pedalService, gasTankService);
                                            ^

Integration Tests Optimieren

Trotz dieses Problems haben die Integration Tests den Nachteil, dass sie recht langsam laufen und wir relativ spät unsere Ergebnisse bekommen. Ein Kern des Vortrags war es nun die Ausführung der Integration Tests zu beschleunigen, um die negativen Seiten der Integration Tests abzumildern. Dafür schauen wir uns einmal an, was die Tests nun langsamer macht. 1. Wir haben eine Datenbank, die bereit gestellt werden muss. 2. Die Datenbank muss mit einem Schema versorgt werden. 3. Die Testdaten für den Test müssen bereitgestellt werden. Damit ist die Datenbank fertig und der Application Context (z.B.: Spring Boot) muss geladen werden. 4. Der Test muss ausgeführt werden. Nun wird der Kern des evident: Es gibt eine Menge Overhead zu dem eigentlichen Test. Die Erfahrung zeigt, dass die Integration Tests selbst recht schnell sind. Das Auf- und Abbauen der Abhängigkeiten dauert jedoch relativ lange. Deshalb sollte man möglichst viele dieser Punkte zusammenfassen und nur einmalig für alle Tests ausführen. Je mehr einmalig ausgeführt wird, desto mehr Zeit wird gespart.

Im Spring Framework ist das Wiederverwenden des Application Context mittlerweile zum Standard geworden – solange nicht bestimmt Annotation wie @MockBean benutzt werden. In vielen Projekten wird zum Testen die Datenbank als Docker Container über Testcontainers gestartet. Hier kann man über die Konfiguration das Wiederverwenden erlauben und dann über die Funktion withReuse aktivieren.

testcontainers.reuse.enable=true
public static GenericContainer dbContainer = new DBContainer().withReuse(true); 

Zuletzt haben wir noch die DB Schema-Initialisierung und die Test Daten. Im aktuellen Projekt initialisieren wir das Schema und spielen die Testdaten über Flyway ein. In älteren Projekten haben wir auch erfolgreich Liqibase benutzt. Diesen Satz an Daten wollen wir vor jedem Test wieder haben und nicht durch die Tests verändern. Dafür kann man einen Spring Test mit @Transactional kennzeichnen. Mit der Annotation finden alle Änderungen in einer Transaktion statt und werden nach dem Test wieder zurück gerollt. Damit haben wir wieder saubere Testdaten zum Start des nächsten Test. Mit diesen Optimierungen kann man die Ausführungszeit der Integration-Testssuite deutlich senken.

Vergleich der Integration Test Laufzeiten ohne und mit Optimierungen

Fazit

Das eigene Testkonzept umzustrukturieren, ist immer einen Blick wert. Als ersten Schritt kann man damit starten, die Integration Testsuite zu optimieren. Selbst wenn man schließlich nicht weniger Unit Tests und mehr Integration Tests schreibt, hat man immer noch einen schnellere Testsuite. Als zweiten Schritt könnte man überlegen, alle Unit Tests, die bei einem Refactoring Probleme machen, danach durch Integration Tests zu ersetzen. In dem Moment, in dem Tests Probleme machen, sollte man den Aufwand investieren und wenig Mehraufwand das Problem dauerhaft lösen.

Als letztes möchte ich euch noch das github-Repro von Arne Limburg zeigen. Dort findet ihr das Projekt TransactionUnit mit dem noch einige Sonderfälle bei Problemen mit @Transactional Unit-Test gelöst werden.

https://github.com/ArneLimburg/transactionunit
Quellen: Javaland 2023 Vortrag Löscht Eure Unit-Tests von Arne Limburg

Teilen Sie diesen Beitrag

Das könnte dich auch interessieren …