Java Magazin   1.2024 - Testing

Preis: 9,80 €

Erhältlich ab:  November 2023

Umfang:  100

Autoren / Autorinnen: 
Marius Nied ,  
Roland Weisleder ,  
Ferdinand Schneider ,  
Stefan Pudig ,  
Julius Mischok ,  
Jan Leßner ,  
Nikolai NeugebauerFrank Steimle ,  
Elena Bochkor ,  
Sebastian Springer ,  
Michael Egger-Zikes ,  
Oliver SteenbuckBent Krause ,  
Alexander Kaserbacher ,  
Lars Kölpin-Freese ,  
Dr. Veikko Krypczyk ,  

Die Agenda ist diesmal voll und vielseitig: Von Anton Bruckner bis zum schiefen Turm von Pisa, von Juvenal bis hin zu den Pyramiden, von der Jagd auf lebendige Mutanten bis hin zum Sowjetführer Lenin – diesmal bleibt wirklich kein Thema unerwähnt!

Aber Moment, handelt es sich hier nicht ums Java Magazin? Bevor Sie nun aufs Cover schauen, liebe Leserinnen und Leser, um sich zu vergewissern, möchte ich sie beruhigen: Alles hat seine Richtigkeit, Sie lesen im Java Magazin – und doch kommt alles oben Genannte zur Sprache.

Auch wollen wir mit diesem Einstieg nicht testen (Achtung, versteckter Hinweis!), ob möglichst mirakulöse Keywords die Leserin und den Leser bei der Editorial-Stange halten. Vielmehr widmen wir uns in diesem Monat einem Thema, dessen Beschreibung nicht nur ein vielseitiges Autorenteam erfordert, sondern auch eine Vielzahl von Bildern und Vergleichen, die es illustrieren. Die Rede ist vom Thema Testing.

Dass Vertrauen gut ist, Kontrolle aber besser, das wissen wir seit dem oben erwähnten Wladimir Iljitsch Uljanow – zumindest wird kolportiert, dass Lenin diesen Ausspruch geprägt habe. So oder so passt er auf jeden Fall gut zum Thema Testing. Wie gut, das zeigen die insgesamt sechs Artikel, die unseren Schwerpunkt in diesem Monat ausmachen.

Den Anfang macht Roland Weisleder mit seinem „Schiefen Turm von Java“, der sich mit ArchUnit beschäftigt. Er übergibt an Ferdinand Schneider, in dessen Text zwar tatsächlich lebendige Mutanten gejagt werden, was aber deutlich weniger martialisch abläuft, als das evozierte Bild glauben machen mag. Es folgt ein unter das erwähnte Leninsche Zitat gestellter Artikel von Stefan Pudig zum Thema „CDC Testing mit Spring Cloud Contract“, worauf Julius Mischoks Artikel folgt, der sich mit „Testdatenmanagement mit Spring Boot“ beschäftigt. Unsere „Testreihe“ geht weiter mit Jan Leßners Ideen für besseres Testen, um schließlich im zweiten Teil unserer Serie zum Thema „Consumer-driven Contract Testing in Event-getriebenen Architekturen“ von Frank Steimle und Nikolai Neugebauer zu münden.

Sie sehen, es handelt sich um einen fulminanten, sechsfachen Testing-Rundumschlag, was mich auf den Gedanken bringt, dass dieses Editorial eigentlich nicht unter der Überschrift „Test, Test, Test“, sondern „Testing, Testing, Testing, Testing, Testing, Testing“ hätte stehen müssen – so viel zum Thema Keywords.

In diesem Sinne viel Freude bei unserem Schwerpunkt zum Thema – Sie wissen, welches Thema.

nied_marius_sw.tif_fmt1.jpgMarius Nied | Redakteur

Mail Website Twitter

Um Tests für nicht triviale Funktionalitäten zu erstellen, sind häufig umfangreiche Testdatensets nötig. Sie zu erstellen und zu pflegen ist nicht einfach. Mal verbleiben Daten in der Datenbank, die andere Tests beeinflussen, mal teilen sich mehrere Testklassen Testdaten und sind dadurch gekoppelt. Höchste Zeit für Ordnung im Datenchaos!

Die Prinzipien der testgetriebenen Entwicklung (Test-driven Development, TDD) sind aus der modernen Softwareentwicklung nicht wegzudenken. Ein sinnvolles Testgerüst stellt die Qualität langfristig sicher und verhindert Fehlerregression. Zudem setzt sich zunehmend die Erkenntnis durch, dass die sinnvolle Anwendung von TDD zu einer besseren Architektur der Software führt.

Sind die Prinzipien und das Vorgehen von TDD in der Theorie noch relativ einfach erklärt, treten bei der konkreten Anwendung unterschiedliche Hindernisse auf. Besonders viel Zeit erfordert es, komplexere Testdaten-Set-ups zu erstellen. Der hohe Aufwand führt oft dazu, dass nicht testgetrieben entwickelt wird und insbesondere während der Wartung und Erweiterung von Systemen keine Tests mehr ergänzt werden. Welche Konzepte zur Erstellung und Pflege von Testdaten erleichtern die Projektarbeit und vor allem das TDD? Wodurch zeichnet sich gelungenes Testdatenmanagement aus?

Was Testdatenmanagement erreichen soll

Strukturiertes Testdatenmanagement verfolgt unterschiedliche Ziele. Wie so oft konkurrieren diese zumindest teilweise miteinander, sodass Projektteams einen Konsens finden müssen. Tabelle 1 zeigt die vier im Folgenden näher erläuterten Ziele des Testdatenmanagements.

Ziel

Beschreibung

Schwache Kopplung

Änderungen an den Testdaten einer Klasse oder Methode haben möglichst wenig Einfluss auf andere Tests.

Isolation

Alle Tests können in beliebiger Reihenfolge ausgeführt werden, ohne ihr Verhalten, also Erfolg oder Fehlschlag, zu ändern.

Reproduzierbarkeit

Tests ändern ihr Verhalten nur bei Änderung des zugrunde liegenden Produktionscodes.

Transparenz

Die Datengrundlage eines Tests ist von der Testmethode aus möglichst einfach feststellbar.

Tabelle 1: Ziele des Testdatenmanagements

Ein valider Kritikpunkt hinsichtlich der falschen Anwendung von TDD ist die starke Kopplung zwischen Test- und Produktionscode einerseits und zwischen dem Code unterschiedlicher Testklassen andererseits. Das Verhalten, also der Erfolg oder Fehlschlag, eines einzelnen Tests sollte nur von Änderungen im zu testenden Produktionscode abhängen. Teilen sich mehrere Tests ein Daten-Set-up, verhindert das zwar Redundanz im Testcode, sorgt aber potenziell für Probleme bei der Erweiterung eines Tests. Werden Daten für einen zu testenden Sonderfall oder Bug angepasst, ist die Wahrscheinlichkeit relativ hoch, dass ein anderer Test fehlschlägt – ohne dass Produktionscode geändert wird. Zielsetzung des strukturierten Testdatenmanagements ist eine möglichst schwache Kopplung zwischen den Testdaten unterschiedlicher Testklassen.

Ein besonders zeitraubender Fall von „Worked on my machine“ hat seine Ursache in einem unglücklichen Testdatenmanagement. Lokal laufen alle Tests fehlerfrei durch, aber in der CI/CD Pipeline schlägt plötzlich ein Test fehl. Probleme dieser Art sind äußerst unangenehm in der Analyse. Sie haben ihre Ursache oft in der geänderten Reihenfolge der Testausführung auf unterschiedlichen Umgebungen. Ein in der Pipeline zu einem früheren Zeitpunkt laufender Test erzeugt Daten im System, die einen später laufenden Tests stören und Fehlschläge erzeugen. Problematisch in der Analyse ist, dass das Fehlverhalten nicht im fehlschlagenden Test liegt, sondern in einem vorangegangenen. Auch tritt das Verhalten bei isolierter Ausführung des Tests nicht auf. Zielzustand des Testdatenmanagements ist also die Isolation der einzelnen Tests. Das bedeutet, dass alle Tests innerhalb der Klassen und alle Klassen in der gesamten Testsuite in beliebiger Reihenfolge ausgeführt werden können, ohne dass sich Erfolg und Fehlschlag ändern.

Ähnlich unangenehm und zeitraubend sind sogenannte Flaky-Tests. Der Begriff beschreibt Tests, die ihr Verhalten ohne Änderung des zugrunde liegenden Codes ändern. Der Test zeigt bei zwei aufeinanderfolgenden Durchläufen ein unterschiedliches Ergebnis. Ziel des Testdatenmanagements ist Reproduzierbarkeit, also ein verlässliches Testergebnis, egal wann der Tests ausgeführt wird.

Testgetriebene Entwicklung strukturiert Tests gewöhnlich in die Bestandteile Given, When und Then. Das macht transparent, auf welcher Datengrundlage der Test läuft, welche Operation das System ausführt und welches Verhalten der Test erwartet. Komplexere Tests ganzer Geschäftsfälle erfordern meist eine relativ umfangreiche Dateneingabe. Dadurch sind die Testdaten in der eigentlichen Testmethode nicht mehr auf einen Blick zu erfassen. Um zum Beispiel explizit feststellen zu können, welcher Wert als Vorname des Users in den Test eingeht, sind mehrere Klicks in geerbte und überladene Methoden oder die Suche in einem SQL-Skript notwendig. Hier stellt das strukturierte Testdatenmanagement die Transparenz der Testdaten für die Testmethode sicher: Es sollen möglichst alle eingehenden Daten von der Testmethode aus schnell einsehbar sein.

Service-Tests im Fokus

Die klassische Testpyramide nach Mike Cohn [1] unterscheidet zwischen Unit-, Service- und UITests. Letztere erfordern ein anderes Toolset, daher werden sie in diesem Artikel nicht näher beleuchtet. Klassische Unit-Tests nutzen vermehrt die Möglichkeit, Klassen und damit die Bereitstellung von Testdaten zu mocken. Eine Klasse oder ein Interface erhält für bestimmte Operationen eine statische Antwort. Diese Daten gehen dann in den Test ein. Hierbei handelt es sich um Whitebox-Tests, sie kennen also Implementierungsdetails – und erzeugen dadurch eine starke Kopplung zwischen Produktions- und Testcode.

Service-Tests sind meist Blackbox- oder zumindest Graybox-Tests. Sie betrachten das zu testende System von außen als Blackbox mit öffentlichen Schnittstellen. Teilweise prüfen sie zum Beispiel anhand von Spys noch assoziierte Operationen wie einen erfolgten E-Mail-Versand. In diesem Moment wird aus der Blackbox eine Graybox. Spring Boot bietet eine hervorragende Struktur zur Umsetzung von Service-Tests. Insbesondere für HTTP-basierte Schnittstellen ist die Blackbox klar abgegrenzt, aber auch in anderen Architekturen finden die Prinzipien Anwendung.

Durch die klare Abgrenzung mittels einer Schnittstellendefinition und die Möglichkeit zur Abbildung von Tests für komplette Geschäftsvorgänge bieten Service-Tests einen großen Hebel, um Systeme durch automatisierte Tests zu verbessern. Andererseits erfordern komplette Geschäftsvorgänge oftmals ein umfangreiches Testdaten-Set-up. Die im Folgenden ausgeführten Strategien fokussieren sich daher auf Service-Tests, insbesondere gegen HTTP-Schnittstellen.

Verschiedene Arten von Testdaten

Tests liefern mit einem einfachen Aufbau den höchsten Nutzen: Sie stellen die Ausgangssituation des Systems sicher (Given), führen eine Aktion aus (When) und prüfen zuletzt die Erwartungen an den geänderten Systemzustand (Then). Testdatenmanagement betrifft im Wesentlichen das „Given“: Es beeinflusst den Ausgangszustand des Systems vor dem Testlauf. Um die später vorgestellten Strategien zur Testdatenerzeugung sinnvoll kombinieren zu können, ist eine Differenzierung unterschiedlicher Arten von in der Datenbank gespeicherten Testdaten sinnvoll.

Konfigurationsdaten sind Daten, die für die grundlegende Funktion des Systems notwendig sind. Solche Daten können zur Laufzeit in der Anwendung nicht geändert werden. Mandantendaten repräsentieren das Grund-Set-up eines funktionsfähigen Systems. Als Faustregel gilt, dass all diese Daten durch fixe Identifier referenzierbar sein sollten. Sie sind der kleinste gemeinsame Nenner für alle Tests des Systems und enthalten nur die minimale Grundstruktur, auf der alle Testszenarien aufbauen. Szenariodaten beschreiben die Daten, die am Ende tatsächlich die Ausgangssituation für den Testfall liefern. Sie bauen die Datengrundlage für Tests detailliert auf. Tabelle 2 enthält Beispiele für diese Testdatenarten.

Art

Beispiele

Konfigurationsdaten

Bestellstatus im Onlineshop, implementierte Zahlungsmethoden, globale Systemkonfiguration

Mandantendaten

Grundkonfiguration Webshop mit Kategorien, Fuhrparkinstanz, Administratoraccounts

Szenariodaten

Kundeninstanz mit Bestellungen, historische Sensordaten, Konfiguration eines Projekts

Tabelle 2: Verschiedene Arten von Testdaten

Testdaten bereitstellen

Zur Demonstration der Strategien zur Bereitstellung von Testdaten dient das Beispielprojekt unter [2]. Das Repository umfasst eine minimale Spring-Boot-Anwendung: Sie enthält lediglich die JPA-Entity Person und ein Repository. Außerdem definiert sie eine Testklasse mit drei Methoden. Auf dem master-Branch schlagen die Tests fehl, da die im Hintergrund gestartete Datenbank leer ist. Verschiedene Branches zeigen isoliert die Strategien zur Bereitstellung von Testdaten, mit denen die Tests am Ende erfolgreich durchlaufen.

Das Projekt verwendet eine relationale H2-In-Memory-Datenbank. Spring Data JPA erzeugt in diesem Fall per Default die Datenbankstruktur aus den vorliegenden Entity-Definitionen. Was für Prototyping und einen schnellen Projektstart attraktiv erscheint, ist für Produktivsysteme nicht empfehlenswert. Daher ist die Strukturerzeugung in der Datei application.properties explizit ausgeschaltet:

spring.jpa.hibernate.ddl-auto = none

Die vorgestellten Strategien zeigen die Testdatenbereitstellung isoliert. In realen Projekt-Set-ups kommen sie gewöhnlich in Kombination zum Einsatz.

Globale Set-up-Skripte

Das „Convention over Configuration“-Prinzip ist einer der Gründe für die Popularität von Spring Boot. Liegen Dateien oder Klassen unter dem richtigen Namen auf dem Classpath, beginnt die Spring-Magie. Natürlich gibt es insbesondere einen Weg, um Testdaten für Unit-Tests zu initialisieren. Der Branch strategy/globalscripts im Beispielprojekt zeigt das Vorgehen. Genau genommen spielt nicht nur ein Skript Testdaten ein, vielmehr sorgt ein zweites zuvor für die Schemaerstellung der Datenbank. Die beiden Skripte schema-h2.sql (Listing 1) und data.sql liegen unter src/test/resources, landen also direkt auf dem Classpath (Abb. 1).

Listing 1

CREATE TABLE person (
  id INT,
  first_name VARCHAR(50),
  last_name VARCHAR(50),
  email VARCHAR(256),
  gender VARCHAR(12),
  ip_address VARCHAR(15)
);
mischok_spring boot testing_1.tif_fmt1.jpgAbb. 1: Ordnerstruktur globale Skripte

Beim Hochfahren des Spring Context führt das Framework zunächst den Inhalt der Datei schema-h2.sql als Datenbankbefehl aus. Laut Dokumentation soll die Datei DDL-Befehle enthalten, also nur das Schema initialisieren. Dagegen erwartet das Framework in der Datei data.sql DML-Befehle, also das Anlegen von Datensätzen (Kasten: „DDL und DML“).

DDL und DML

  • DDL: Unter Data Description Language fallen diejenigen SQL-Befehle, die das Datenbankschema ändern. Diese Befehle können nicht auf allen Datenbanken durch ein Rollback rückgängig gemacht werden.

  • DML: Die Data Manipulation Language umfasst alle SQL-Befehle zum Lesen, Schreiben und Löschen von Daten. Die Ausführung geschieht in einer Datenbanktransaktion.

Finetuning

Um unterschiedlichen Datenbankdialekten, zum Beispiel bei der Verwendung einer Embedded-Datenbank in den Tests, zu begegnen, verwendet Spring Boot den Plattformtyp aus dem Konfigurationsparameter spring.sql.init.platform. In früheren Versionen hieß der Parameter spring.datasource.platform. Entsprechende spezielle Skripte mit dem Namensschema schema-${platform}.sql und data-${platform}.sql führt das Framework nur aus, wenn der Plattformtyp übereinstimmt. Insbesondere wird die allgemeine schema.sql vor der speziellen schema-${platform}.sql ausgeführt, entsprechend für die data-Variante.

Die sprichwörtliche Spring-Magie ist äußerst hilfreich – manchmal jedoch zunächst verwirrend. So unterscheidet das Framework sehr klar zwischen Embedded-Datenbanken und solchen, die außerhalb der eigenen JVM laufen. Für eingebettete Datenbanken steht der Konfigurationsparameter spring.jpa.hibernate.ddl-auto auf dem Wert create-drop, sobald Spring Data JPA auf dem Classpath liegt. Mit dieser Konfiguration erstellt JPA beim Hochfahren der Anwendung die notwendige Datenbankstruktur für die JPA-Entity-Klassen. Liegt zusätzlich eine schema.sql-Datei vor, schlägt der Applikationsstart fehl, da das Datenbankschema nicht doppelt angelegt werden kann. In den meisten Fällen ergibt daher die Konfiguration spring.jpa.hibernate.ddl-auto = none Sinn. Damit erhält die Initialisierung per Skript Vorrang vor der JPA-Initialisierung. Die Datenbankinitialisierung mittels globaler Skripte ist per Default nur für Embedded-Databanken aktiviert. Soll das Feature zum Beispiel eine Postgres-Datenbank initialisieren, muss der Parameter spring.sql.init.mode auf den Wert always gesetzt werden.

Tatsächlich gibt es ein wichtiges Argument gegen die Verwendung globaler Testdatenskripte: Diese sind sinnvoll, sobald für Tests eine Embedded-Datenbank zum Einsatz kommt. Jedoch empfiehlt die aktuelle Spring-Dokumentation explizit, die globalen Skripte nicht mit sogenannten Higher-Level-Database-Migration-Tools, also Flyway oder Liquibase zu kombinieren [3].

Derzeit funktioniert die Kombination noch, der Support soll jedoch in einer späteren Version entfallen. Da die Verwendung eines Datenbankmigrationstools in den meisten Projekten sinnvoll ist, kommen die globalen Skripte in Produktion kaum zum Einsatz. Lediglich zur Schemaerstellung und zur Bereitstellung von Konfigurationsdaten kommen sie infrage.

Skripte auf Testebene

Ein mit den globalen Skripten vergleichbares Konzept besteht für Testklassen und ist sogar für einzelne Testmethoden nutzbar. Die Annotation @Sql führt SQL-Skripte vor jeder Testausführung gegen die konfigurierte Datenbank aus. Der PersonRepositoryTest im Branch strategy/testscripts des Beispielprojekts verdeutlicht die Verwendung (Listing 2). Die Datenbankoperationen sind wieder auf zwei Skripte aufgeteilt: schema-init.sql legt das Schema an, data-init.sql die vom Test erwarteten Personendaten. Beide Dateien liegen unter src/test/resources und werden über den Classpath adressiert. Da die Einbindung explizit per Name erfolgt, ist die Benennung frei wählbar.

Listing 2

@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Transactional
@Sql({"classpath:/schema-init.sql", "classpath:/data-init.sql"})
public class PersonRepositoryTest {
  ...
}

Listing 3

CREATE TABLE IF NOT EXISTS person (
  id INT,
  first_name VARCHAR(50),
  last_name VARCHAR(50),
  email VARCHAR(256),
  gender VARCHAR(12),
  ip_address VARCHAR(15)
);

Die Skripte werden in der angegebenen Reihenfolge ausgeführt. Mit dem ersten Skript legt das Framework im Beispiel die Struktur an, mit dem zweiten die Testdaten. Bei der aufmerksamen Durchsicht der Datei schema-init.sql in Listing 3 fällt der Einschub IF NOT EXISTS auf. Ohne diesen läuft die Ausführung der gesamten Testklasse in einen Fehler. Hintergrund ist die Tatsache, dass DDL-Befehle, also Änderungen am Datenbankschema, in JPA nicht in Datenbanktransaktionen ausgeführt werden. Während die durch das Skript data-init.sql angelegten Datensätze nach jedem Testdurchlauf zurückgerollt werden, bleibt das Schema bestehen und führt im zweiten Test zum Fehler. Daran ändert auch die Annotation @Transactional nichts.

Das zeigt bereits den komplett konträren Ansatz zwischen globalen und testbasierten Skripten: Der Start des Spring Context führt die globalen Skripte aus, das geschieht nur einmalig pro Testklasse. Dagegen führt jede Testklasse die per @Sql annotierten Skripte bei jeder Ausführung einer Testmethode aus.

Auf Methodenebene

Wie bereits angedeutet muss die Annotation @Sql nicht auf Klassenebene stehen, Spring interpretiert auch die Verwendung auf Methodenebene. Es ist jedoch zu beachten, dass die Annotation per Default nur einmal angewendet wird: Die Annotation an der Testmethode gewinnt gegen die Annotation auf Klassenebene. Mehr noch – eine Annotation an der Testklasse überschreibt auch etwaige @Sql-Annotationen von Elternklassen.

Das Spring-Framework erlaubt seit Version 5.2 die Steuerung dieses Verhaltens durch die Annotation @ SqlMergeMode. Aktiviert man den Modus MERGE, führt das Framework zuerst die auf Klassenebene konfigurierten Skripte aus, dann die auf Methodenebene definierten.

Die Skripte auf Testebene bieten sich zur Abbildung komplexerer Test-Set-ups an und enthalten vorwiegend Mandanten- und Szenariodaten. Die flexible Steuerung auf Methodenebene ermöglicht die Initialisierung unterschiedlichster Ausgangssituationen für Tests.

Higher-Level-Migration-Tool

Die meisten Softwareprojekte setzen heutzutage Tools zur Datenbankmigration ein. Diese verwalten Änderungen an der Datenbank in inkrementellen Skripts. Beim Hochfahren der Anwendung gleicht die Bibliothek den Stand der Datenbank mit den zur Verfügung stehenden Skripten ab und führt neuere Skripte aus. Diese relativ einfache Idee ermöglicht Datenbankupdates über mehrere Entwicklungs-Stages hinweg. Natürlich unterstützt sie auch bei der Generierung von Testdaten.

Eines der etablierten Frameworks ist Flyway. Die Einbindung und Grundkonfiguration wird im Testprojekt im Branch strategy/flyway vorgenommen. Die Ideen für Flyway sind mit Liquibase, dem zweiten von Spring unterstützten Migrationstool, ebenso anwendbar. Per Default betrachtet Flyway alle SQL-Skripte, die unter classpath:db/migration liegen. Die einzelnen Skripte können beliebig lang oder kurz sein und dürfen sowohl Schemaänderungen enthalten als auch Daten manipulieren. Das ist der Grund, warum in der Praxis eine Differenzierung zwischen Schemaerstellung und Testdatenerstellung eigentlich obsolet ist: Ein Migrationstool kümmert sich in jedem Fall um das Schema und zumeist um die Konfigurationsdaten des Systems.

Mandantendaten mit Flyway erstellen

Mit einem kleinen Trick ermöglicht Flyway auch die Anlage von Mandantendaten. Dafür lässt sich das Classpath-Management in Maven-Projekten nutzen: Die eigentlichen Migrationsskripte liegen physisch im Ordner src/main/resources/db/migration. Maven legt diesen Ordner auf den Classpath, überschreibt und ergänzt ihn dann jedoch durch die Inhalte des physischen Ordners src/test/resources/db/migration.

Flyway führt die Migrationsdaten immer in einer festen Reihenfolge aus. In der Defaultkonfiguration beginnt der Dateiname der Migrationsdateien stets mit dem Buchstaben V, dann kommt eine Versionsnummer, gefolgt von zwei Underscores. Das Beispielprojekt enthält eine Migration unter src/main/resources/db/migration, dieses Skript legt das Schema an. Als erste Migration der Applikation trägt es den Namen V1__init-schema.sql. Es ergänzt dann für Tests ein weiteres Skript mit dem Namen V9999__setup-testdata.sql, es liegt unter src/test/resources/db/migration. Es enthält die für den Test notwendigen Personendaten. Durch die künstlich hohe Versionsnummer wird es immer nach den Produktivskripten ausgeführt und gelangt insbesondere niemals in das ausgelieferte Artefakt. Da es jedoch einmalig für alle Tests ausgeführt wird, hat die Verwendung nur für Mandantendaten Sinn.

Migrationen und @Transactional

Durch die Ausführung der Migrationen beim Start des Spring Context haben etwaige @Transactional-Annotationen an Klasse oder Methode keinen Einfluss auf die Daten. Alle per Migrationsskript angelegten Daten bleiben auch beim Zurückrollen der Testtransaktion im System. Das hat zur Folge, dass per Flyway angelegte Mandantendaten nicht durch Tests geändert werden dürfen, die nicht in einer Transaktion ausgeführt werden. Ansonsten könnten sich nachfolgend ausgeführte Klassen nicht mehr auf das feste Set der Mandatendaten verlassen.

Inkompatible Migrationen überschreiben

Die Tatsache, dass Maven die Dateien aus src/test/resources über die Dateien aus src/main/resources kopiert, ermöglicht einen weiteren praktischen Kniff: Inkompatible Migrationen können für die Tests überschrieben werden. In gewissen Fällen lohnt es sich, für Tests zum Beispiel eine Embedded-Datenbank wie H2 zu verwenden. Diese ist zwar sehr verständnisvoll für herstellerspezifischen SQL-Dialekt, der Support hat jedoch Grenzen. Liegt eine Migration vor, die schon produktiv läuft, also nicht mehr geändert werden kann, überschreibt eine gleichnamige, kompatible Datei unter src/test/resources/db/migration die fehlschlagende Migration für die Testausführung.

Testdatenerzeugung im Java-Code

Die bisherigen Strategien legen allesamt Testdaten per Skript in der Datenbank an. Das hat jedoch einen großen Nachteil: Schlägt ein Test fehl, ist die Datengrundlage des Tests nicht auf einen Blick transparent. Insbesondere bei der Kombination eines Datenbankmigrationstools und der @Sql-Annotation bleibt durch die inkrementelle Anlage der Daten oft nur der Debugger, um den tatsächlichen Datenstand bei Testbeginn zu ermitteln.

Im Testcode angelegte Daten, zum Beispiel mit Hilfe von JPA-Repositorys und den zugehörigen Entity-Klassen, sind dagegen auf einen Blick zuzuordnen. Szenariodaten enthalten zudem oft generierte IDs, die sich bei jedem Testdurchlauf ändern. Über Skripte ist das schwieriger abzubilden als über im Code erstellte Testdaten.

Testdatenerstellung auf Klassenebene

Testframeworks wie JUnit ermöglichen die Ausführung speziell annotierter Methoden vor und nach der Testklasse sowie vor und nach jeder Testmethode. Im Branch strategy/java zeigt das die Testklasse PersonRepositoryTest. Der Code aus Listing 4 läuft vor jeder einzelnen Testmethode und legt tausend Personen-Entitys an. Eine davon soll später im Test referenzierbar sein und wird daher in einer Klassenvariable gespeichert. Moderne Entwicklungsumgebungen unterstützen bei der schnellen Analyse des Datenstandes der Variablen und damit des Testszenarios. Der Code im Beispielprojekt verwendet Zufallswerte für die Daten der anzulegenden Personen. In der Praxis ist davon abzuraten, mehr dazu weiter unten.

Listing 4

@BeforeEach
public void setUp() {
  Stream.iterate(1, i -> i+1)
    .limit(1000 - personRepository.count())
    .forEach(i -> {
      Person person = Optional.of(i)
        .filter(id -> id == 32)
        .map(id -> Person.builder()
        .id(Long.valueOf(i))
        .firstName("Arlena")
        .lastName("Hanny")
        .email("ahannyv@hao123.com")
        .gender("Female")
        .ipAddress("28.61.169.27")
        .build())
      .orElse(Person.builder()
        .id(Long.valueOf(i))
        .firstName(UUID.randomUUID().toString())
        .lastName(UUID.randomUUID().toString())
        .email(UUID.randomUUID().toString())
        .gender(UUID.randomUUID().toString().substring(0, 11))
        .ipAddress(UUID.randomUUID().toString().substring(0, 14))
        .build());
 
      person = personRepository.save(person);
 
      if (i == 32) {
        arlenaHanny = person;
      }
  });
}

Im Codebeispiel tragen alle drei Testmethoden die Annotation @Transactional. Daher werden unabhängig vom Testergebnis alle im Rahmen des Tests erstellten Daten per Datenbank-Rollback zurückgesetzt. Falls kein @Transactional verwendet werden kann oder soll, empfiehlt es sich, im Test angelegte Daten zum Beispiel in einer mit @AfterEach annotierten Methode wieder zu löschen. Andernfalls beeinflussen übrig gebliebene Daten unter Umständen später ausgeführte Tests – einer der häufigsten Gründe für im Gesamtlauf fehlschlagende und bei Einzelausführung erfolgreiche Tests.

Testdatenerstellung in der Testmethode

Sinnvolle Tests folgen, wie oben bereits erwähnt, dem Schema Given –When – Then. Mitunter ist es nötig, das Szenario innerhalb der einzelnen Testmethoden weiter zu verfeinern, um die Ausgangssituation genauer zu beschreiben. Was die Tests zunächst aufbläst, schafft jedoch das höchste Maß an Transparenz: Legt die Testmethode die Daten des Szenarios selbst an, sind sie meist, ohne zu scrollen, bei der Formulierung der Expectations sichtbar.

In jedem Fall ist eine gründliche Abwägung zwischen der Redundanz im Code und der Transparenz anzustreben. Während Mandantendaten keine generierten Identifier enthalten sollten, gilt das Gegenteil für Szenariodaten: Hier sollte jeder Testdatensatz eine möglichst automatisch generierte ID erhalten, um den Test unabhängig zu gestalten (Tabelle 3).

Strategie

Verwendung

Transaktional

globale Skripte

Schemagenerierung, Konfigurationsdaten

nein

Testdatenskripte

Mandantendaten, Szenariodaten

ja

Migrationstools

Schemagenerierung, Konfigurationsdaten, ggf. Mandantendaten

nein

Erzeugung im Java-Code

Szenariodaten

ja

Tabelle 3: Strategien zur Testdatengenerierung

Der Königsweg

Wie bereits angedeutet hat jede Strategie Stärken, Schwächen und vor allem klare Grenzen. Zum Einsatz kommen in Projekten daher Kombinationen, die sich je nach Situation zusammenstellen lassen. Abbildung 2 zeigt ein bewährtes Vorgehen.

mischok_spring-boot_testing_2.tif_fmt1.jpgAbb. 2: Empfohlene Strategien

In der Praxis setzten die meisten Projektteams Datenbankmigrationstools zur Schemagenerierung und für Anpassungen am Datenbankschema ein. Zumeist ist es sinnvoll, Konfigurationsdaten für Tests durch solche Tools anzulegen und zu pflegen. Reproduzierbare Tests erfordern eine leere Datenbank und führen optimalerweise die Migrationen bei jedem Testlauf vollständig aus. Damit sind das Schema und die grundlegenden Konfigurationsdaten beim Start des Spring Context aktuell.

Mandantendaten sind die Grundlage für mehrere Testklassen. Daher ist es hilfreich, sie als zentrales Skript zu pflegen und per klassenbasiertem Testdatenskript mittels der Annotation @Sql einzubinden. Werden zwei oder drei Grundkonfigurationen in minimalen Scripts parallel gepflegt, haben die Testklassen eine stabile Datengrundlage zum Aufbau eigener Testszenarien.

Für nach dem Schema Given – When – Then aufgebaute Tests bietet sich der Aufbau der Szenariodaten, also der Ausgangslage für den Given-Teil, im Java-Code der Testklasse an. Von mehreren Testmethoden verwendete Testdaten legt eine mit @BeforeEach annotierte Methode als gemeinsames Set-up an; die für die Testmethode individuellen Daten erzeugt die Methode selbst. Auf diese Weise aufgebaute Testdaten-Set-ups sind sehr robust und gut wartbar. Leider erfordern besonders Bestandsprojekte Kreativität bei der Kombination der Strategien zur Testdatengenerierung. Dabei ist es wichtig, die Charakteristiken der Strategien mit ihren Vor- und Nachteilen zu verstehen und geschickt anzuwenden.

Je kleiner das gesamte Set aus Konfigurations-, Mandanten- und Szenariodaten ist, desto besser. Die Projektpraxis erfordert daher einen hohen Fokus auf die Zusammenstellung minimaler Testdatensets, die möglichst alle Szenarien abdecken. Die Findung einer sinnvollen Datengrundlage für ein Szenario ist nicht selten anspruchsvoller als die eigentliche Implementierung. Bei der Ausarbeitung einer individuellen Strategie zum Testdatenmanagement unterstützen die folgenden Best Practices:

  • Umgang mit Shared Databases: In seltenen Fällen erfordert das Projekt die Einbindung einer gemeinsamen, nicht lokal verfügbaren Datenbank für alle Testläufe. Das ist zum Beispiel der Fall, wenn sich das verwendete Datenbanksystem nicht ohne erheblichen Aufwand containerisieren oder in eine Embedded Database integrieren lässt. Generell ist von Shared Databases für Testläufe abzuraten, da sich parallele Testläufe immer beeinflussen werden. Ist die Nutzung einer gemeinsamen Datenbank alternativlos, liegt ein besonderes Augenmerk auf den Testtransaktionen: Durch sie geschieht die Abgrenzung der Daten für die einzelnen Tests. Bei konsequenter Verwendung von Transaktionen für jeden einzelnen Testlauf können sowohl Testdatenskripte als auch die Erzeugung von Testdaten im Java-Code zum Einsatz kommen.

  • Zufallswerte vermeiden: Ein Test soll sein Verhalten, also Erfolg oder Fehlschlag, nur ändern, wenn sich der zu testende Produktivcode ändert. Aus Bequemlichkeit kommen jedoch vor allem bei der Erzeugung in Java-Code häufig Zufallswerte zum Einsatz. Im ungünstigsten Fall führen diese zu einem einmaligen Fehlschlag des Tests, der nicht mehr reproduzierbar ist. Anbieter wie Mockaroo [4] helfen beim Aufbau umfangreicher Testdatensets. Auch diese Werte sind zufallsgeneriert, bleiben bei Verwendung im eigenen Projekt jedoch stabil und sorgen dadurch für reproduzierbare Ergebnisse.

  • Async und Transactional: Durch die Annotation @ Async ermöglicht das Spring-Framework die Implementierung asynchroner Methoden. Vorsicht ist bei der gemeinsamen Verwendung von @Transactional im Test geboten: Spring führt die eigentliche Aufgabe asynchron in einem zusätzlichen Thread aus. Dieser hat insbesondere keinen Zugriff auf die Transaktion eines mit @Transactional annotierten Tests. Bezieht sich die fachliche Prüfung nicht auf die asynchrone Abarbeitung, sondern lediglich auf die korrekten Ergebnisse auf Datenebene, schaltet die Konfiguration aus Listing 5 die asynchrone Abarbeitung im Test aus.

Fazit

Softwareentwicklung ist an vielen Stellen mehr Kunst als Wissenschaft: Komplexe Herausforderungen erfordern kreative Lösungen, die nicht unbedingt standardisierbar sind. Ein durchdachtes Testdatenmanagement ist die Grundlage für robuste und reproduzierbare Tests. Die Auswahl der passenden Strategien für das Testdatenmanagement führt zu wartbaren und gut erweiterbaren Tests und sichert dadurch die Nachhaltigkeit des Softwareprodukts.

Listing 5

@TestConfiguration
public class NoAsyncConfiguration {
 
  @Bean
  @Primary
  public Executor taskExecutorSynced() {
    return new SyncTaskExecutor();
  }
}

Für die Ausarbeitung eines individuellen Testdatenkonzepts ist die Betrachtung und Kategorisierung der unterschiedlichen Arten von Testdaten ein guter Startpunkt. Die geschickte Kombination der Werkzeuge macht das Testdatenmanagement dann zum Kinderspiel!

mischok_julius_sw.tif_fmt1.jpgJulius Mischok ist Geschäftsführer der Mischok GmbH in Augsburg. Seine Kernaufgaben sind Prozessentwicklung sowie Coaching und Schulung der Entwicklungsteams. Aktuell fokussiert sich seine Arbeit auf die Frage, wie Software schnell und mit maximaler Wertschöpfung produziert werden kann. Er hat Mathematik studiert und entwickelt seit fast zwei Jahrzehnten mit Java.

Desktop Tablet Mobile
Desktop Tablet Mobile