Am Anfang eines Projektes wird heutzutage in der Regel Wert auf Tests gelegt. Projekte werden sogar testgetrieben aufgesetzt (Test-driven-development, TDD). TDD-Projekte der reinen Lehre schreiben vor, dass vor jeder Zeile Anwendungscode der entsprechende Testcode geschrieben werden muss. Schleicht sich Stress ein oder lässt die Begeisterung nach, kann es passieren, dass das Thema Tests — obwohl wichtig — vernachlässigt werden. Das gilt umso mehr, je schwieriger Komponenten eines Systems zu testen sind, unabhängig, ob sie einzeln oder integriert betrachtet werden. Lesen Sie hier, wie Ihnen ein Domain-orientierter Ansatz zusammen mit Spring Boot 2 dabei hilft, Qualität sicherzustellen.
Bevor Sie weiterlesen: Für Softwaretests hinsichtlich Anforderungen gilt oftmals das Gleiche wie für Besuche beim Arzt. Eine Diagnose stellt fest, dass es keine Beweise für eine Krankheit gibt. Beweise für die Abwesenheit einer Krankheit gibt es nicht. Sie werden es schwer haben, in einem alltäglichen Projekt, die Abwesenheit von Fehlern hinsichtlich Anforderungen zu beweisen.
Den vollständigen Quelltext dieses Artikels finden Sie auf GitHub.
1. Vertrauensbildende Maßnahmen: Tests und Dokumentation
Tests und Dokumentation sind wichtige Aspekte einzelner Anwendungen und der meisten Anwendungssysteme. Beide Themen sind vielschichtig und finden auf unterschiedlichen Ebenen statt, zum Beispiel auf Methoden-, Klassen-, Modul- und Systemebene. Tests werden genutzt, um zu überprüfen, dass einzelne Komponenten ihre Spezifikation erfüllen. Das sind in der Regel Unittests. Der nächste Schritt ist sicherzustellen, dass Komponenten miteinander funktionieren. Es wird von Integrationstests gesprochen. Regressionstests können sowohl als Unit- als auch Integrationstest ausgeprägt sein. Regressionstests sollen Fehler nach Änderungen von Komponenten aufdecken. Regressionstests müssen also wiederholbar sein, um das Ergebnis eines alten mit dem Ergebnis eines neuen Testfalls vergleichen zu können.
Hinsichtlich Dokumentation ist das Feld ähnlich vielfältig. Es wird von Code-, API-, Architektur- und Anwenderdokumentation gesprochen.
Trotz aller Unterschiede gibt es eine Gemeinsamkeit: Die genannten Maßnahmen schaffen Vertrauen. Vertrauen in die Funktionalität als solche, in die Integrierbarkeit eines Systems und auch darauf, Änderungen vornehmen zu können.
2. Warum sparen wir uns dennoch das Testen?
Konsequente Softwaretests — so sie denn gesetzlich nicht vorgeschrieben sind — stehen oftmals hinten an oder sind nicht integraler Bestandteil von Softwareentwicklung. Im Projektalltag werden oft Varianten folgender Argumente vorgebracht: "Dafür ist keine Zeit da.", "Testen schafft sowieso keinen Mehrwert.", "Diese Module sind nicht testbar." oder auch "Das benutzte Framework macht Tests zu aufwendig."
Demgegenüber sei entgegnet:
-
Die Zeit wird in Summe so oder so aufgewendet. Vielleicht nicht durch dasselbe Team, das einen Service erstellt hat, aber dann durch das Wartungsteam oder den Support. Die nachträgliche Fehlersuche und insbesondere das dann hoffentlich durchgeführte Testen sind teurer.
-
Testen schafft Vertrauen. Vertrauen, das Refactorings und neue Features unterstützt und damit direkt Mehrwert entspricht.
-
Testen stellt sicher, dass nicht geänderte Programmbestandteile nach Refactorings weiter funktionieren.
-
Code, von dem bekannt ist, dass er getestet wird, wird von Anfang an anders und in meinen Augen besser strukturiert, so dass er testbar bleibt.
Schleicht sich in einem Projekt Stress ein, sei es durch zeitlichen Druck, unklare Anforderungen oder anderes mehr, ist es zu spät, Testen noch in den Fokus zu rücken. Hinterher Tests zu schreiben bringt oftmals keinen direkten Mehrwert mehr und eine Nachdokumentation macht selten Spaß.
3. Anforderungen an Werkzeuge und Tests
Es ergeben sich aus den einleitenden Abschnitten mindestens die folgenden Anforderungen an Tests:
-
Der Start eines Projektes darf mit Testunterstützung nicht aufwendiger sein als ohne.
-
Die Tests müssen sich nahtlos in den Entwicklungsprozess integrieren.
-
Sie müssen so schnell wie möglich ausführbar sein.
-
Das Ergebnis sollte meßbar sein.
Die Teams hinter Spring und Spring Boot legen großen Wert darauf, dass ihre Frameworks einen testgetriebenen Softwareenwicklungsansatz unterstützen.
In der Java-Welt hat sich JUnit als Standardwerkzeug zur Ausführung von Tests durchgesetzt. JUnit wird von Spring — auch in der aktuellsten Version 5 — vollumfänglich unterstützt.
Spring-Boot-Anwendungen sind ganz normale Java-Anwendungen, die in der Regel mit einem Build Management Tool gebaut werden. Das Build Management Tool ist unter anderem für die Auflistung und Bereitstellung aller Abhängigkeiten zuständig. Im Beispielprojekt zum Artikel wird das Werkzeug Gradle verwendet.
Spring Boot arbeitet mit sogenannten Startern. Diese Starter stellen Ihnen alle für ein gegebenes Thema notwendigen Abhängigkeiten zur Verfügung. So einen Starter gibt es auch für das Thema "Testen".
Die Deklaration der Abhängigkeit in einem Gradle Build File (build.gradle
) ist sehr einfach, wie Listing 1 zeigt.
dependencies {
testCompile "org.springframework.boot:spring-boot-starter-test"
}
Durch nur eine Deklaration erhalten Sie:
4. Das Beispiel
Ich möchte Ihnen anhand einer einfachen Fachlichkeit zeigen, wie Spring Boot 2 Ihnen dabei hilft, sehr einfach Integrationstests zu schreiben: Sei es als vollständiger Durchstich oder als Integrationstest auf einer technischen Ebene.
Getestet werden soll ein Service, der Events und dazugehörige Registrierungen verwaltet. An einem Tag können mehrere Events stattfinden, die Namen der Events müssen eindeutig sein. Events haben eine begrenzte Teilnehmeranzahl. Interessierte Besucher melden sich mit Namen und E-Mail-Adresse an und sollen sich nicht mehrfach anmelden können.
Der Event-Service könnte Teil einer größeren Anwendung sein und als sogenannter Bounded Context identifiziert worden sein. Der Begriff Bounded Context stammt aus dem Domain-Driven Design (DDD). Ein Bounded Context zielt darauf ab, größere Modelle in kleinere Teile zu zerlegen, die für sich genommen handhabbar sind und definierte Beziehungen untereinander haben. Innerhalb eines Bounded Context wird mit einer gemeinsamen, allgegenwärtigen ("Ubiquitous Language") über ein Thema gesprochen. Diese Fokussierung ist nicht nur für die eigentliche Entwicklung, sondern auch für das Testen wichtig. Es wird klar erkennbar was Teil des Tests sein muss und was nicht. Sie vermeiden damit, in einem allumfassenden Kontext ein ebenso allumfassendes Modell der Welt testen zu müssen.
Die Fachlichkeit eignet sich sehr gut zu zeigen, dass Tests auf Modulebene nicht nur sehr einfach zu realisieren sind, sondern auch oftmals die wichtigsten Aspekte Ihrer Domain bereits erfassen. Betrachten Sie die Klasse Event
in Listing 2.
public class Event implements Serializable {
private LocalDate heldOn;
private String name;
private Integer numberOfSeats;
private Status status;
private List<Registration> registrations = new ArrayList<>();
public Event(final LocalDate heldOn, final String name, final Integer numberOfSeats) { (1)
if (heldOn == null || heldOn.isBefore(LocalDate.now(CLOCK.get()))) {
throw new IllegalArgumentException("Event requires a date in the future.");
}
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Event requires a non-empty name.");
}
this.heldOn = heldOn;
this.name = name;
this.status = Status.open;
this.setNumberOfSeats(numberOfSeats);
}
public Registration register(final Person person) { (2)
if (isPastEvent()) {
throw new IllegalStateException("Cannot register for a past event.");
}
if (isFull()) {
throw new IllegalStateException("Cannot register for a full event.");
}
// Weitere Bedingungen ausgeblendet
this.registrations.add(registration);
return registration;
}
}
1 | Der Konstruktor überprüft alle geforderten Vorbedingungen. Client-Code kann kein ungültiges Event herstellen. |
2 | Die Registrierung selber: Es ist nicht notwendig, Logik dieser Art über einen Service zu implementieren und das Event auf ein blutleeres Modell (anemic domain model) zu reduzieren. |
Die Klasse Event
wird in einem Domain-driven Design Ansatz als Aggregate Root bezeichnet, als Kern Ihrer Domain. Ein Aggregat kapselt mehrere Objekte Ihrer Domain, auf die nur gemeinsam zugegriffen werden darf. Eines dieser Objekte ist das Root-Objekt. Im Beispiel ist Event
das Root-Objekt, die Registrierungen sind Objekte, die nur im Kontext des Events Gültigkeit besitzen. Auch hier hilft das gezielte Abstecken des Rahmens bei der Festlegung dessen, was getestet werden soll.
Event
ist frei von Spring-typischen Annotationen. Schauen Sie in den vollständigen Quelltext, finden Sie allerdings JPA-Annotationen. JPA steht für Java Persistence API und wird genutzt, um die Inhalte relationaler Datenbanken auf Objekte abzubilden. Richtig genutzt verbinden Sie damit sinnvolle Datenbankschemata mit Objekten, die nach den im vorherigen Absatz beschriebenen Prinzipien gestaltet wurden.
5. Die Tests
5.1. Auf Modul-(Unit)-Ebene
Durch die Abhängigkeit spring-boot-starter-test
erhalten Sie alle Bausteine, um Event
einem Unit-Test zu unterziehen. Listing 3 zeigt Tests der erwarteten Pre- und Postconditions.
public static class Preconditions {
@Test (1)
public void constructorShouldNotAllowInvalidNames() {
Stream.of(null, "", "\t", " ").forEach(name ->
assertThatExceptionOfType(IllegalArgumentException.class) (2)
.isThrownBy(() -> new Event(LocalDate.of(2018, 1, 2), name))
.withMessage("Event requires a non-empty name.") (3)
);
}
}
public static class Postconditions {
@Test
public void constructorShouldCreateValidEvents() {
final Integer numberOfSeats = 23;
final LocalDate heldOn = LocalDate.of(2018, 1, 2);
final Event event = new Event(
heldOn, "test", numberOfSeats);
assertThat(event.getNumberOfSeats()).isEqualTo(numberOfSeats);
assertThat(event.isOpen()).isTrue();
}
}
1 | Signalisiert, dass diese Methode von JUnit als Testmethode ausgeführt werden soll |
2 | Hier sehen Sie eine AssertJ-Assertion. AssertJ bietet unter anderem eine schöne Möglichkeit an, zu testen, ob eine bestimmte Exception geworfen wurde oder nicht. |
3 | Weiterhin ist es möglich, Assertions aufeinander aufzubauen: Wenn die Exception dem erwarteten Typen entsprach, wird zusätzlich die entsprechende Mitteilung überprüft. |
Der Test der Logik ist ähnlich aufgebaut. Der Test ist klar strukturiert und gut lesbar, insbesondere weil die zu testende Klasse die Domain gut widerspiegelt. Der Kern des Event-Services kann so mit wenig Aufwand fast vollständig getestet werden. In einem vollständig testgetriebenen Ansatz könnten Sie soweit gehen, dass Sie zuerst den Unit-Test wie in Listing 3 gezeigt schreiben und dadurch Ihre Erwartungen an die Domain formulieren. Da der Test so natürlich nicht kompiliert, müssten Sie anschließend die zu testende Klasse Event
anlegen, die notwendigen Schnittstellen definieren und implementieren, bis der Test kompiliert und schlussendlich von rot (schlägt fehl) auf grün umspringt.
5.2. Fließende Grenzen
Die Events kommen aber nicht aus dem luftleeren Raum. Sie werden in einer Datenbank gespeichert und es muss eine Schnittstelle geben, sie abzurufen. Eine gute Möglichkeit für Datenbankzugriffe vielfältiger Art ist eines der vielen Spring-Data-Module. Spring Data implementiert für Domain-Klassen das Repository Pattern. Ein Repository dient als Schnittstelle zwischen der Domainschicht und dem technischen Zugriff auf Daten. Nach außen stellt es sich oftmals als eine Art Liste von Domainobjekten dar. Spring Data arbeitet dabei deklarativ. Listing 4 zeigt den notwendigen Code für ein Repository von Events.
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.QueryByExampleExecutor;
interface EventRepository extends Repository<Event, Integer>, QueryByExampleExecutor<Event> {
Event save(Event newEvent);
}
In einer Spring-Boot-Anwendung, die den entsprechenden Spring Data Starter als Abhängigkeit deklariert, ist das alles, was Sie tun müssen, um ein Repository dieser Art zur Laufzeit zu erhalten. Dieses Repository müssen Sie nicht testen. In dieser Form gehe ich davon aus, dass das Spring Data Team den Code getestet hat, der zur Laufzeit die save
-Methode implementiert. Was ist aber mit dem Domain Service in Listing 5, der sicherstellt, dass keine doppelten Events gespeichert werden? Aus einem datenbankzentrischen Perspektive kann das Thema natürlich mit einem Unique-Constraint gelöst werden. Damit werden aber Verantwortlichkeiten der Domain auf unterschiedliche Schichten verteilt und damit schlechter sichtbar. Davon abgesehen müsste eine Verletzung des Contraints auch entsprechend behandelt werden. Allerdings hält Sie niemand davon ab, entsprechendes Constraint dennoch zu definieren, so wie in diesem Projekt.
Die Klasse EventService
nutzt das Repository und eine der zur Laufzeit bereitgestellten Query-Methoden:
public class EventService {
private final EventRepository eventRepository;
public Event createNewEvent(final Event newEvent) {
this.eventRepository.findOne(newEvent.asExample())
.ifPresent(e -> {
throw new DuplicateEventException(e);
});
return this.eventRepository.save(newEvent);
}
}
An dieser Stelle sind zwei Dinge zu testen: Funktioniert die Methode asExample
auf der Domain-Klasse wie erwartet und reagiert der Service wie erwartet auf das Vorhandensein von Events. Um die Logik des Service zu testen, nutze ich einen Mock. Ein Mock ist eine Attrappe, ein Platzhalter der in Unit-Tests genutzt werden kann und so tut, als ob er notwendige Funktionalität implementiert. Spring Boot stellt Ihnen im entsprechenden Starter alle Werkzeuge zur Verfügung:
@RunWith(MockitoJUnitRunner.class) (1)
public class EventServiceTest {
@Mock (2)
private EventRepository eventRepository;
@Test
public void shouldNotCreateDuplicateEvents() {
when(eventRepository.findOne(halloween().asExample()))
.thenReturn(Optional.of(halloween())); (3)
final EventService eventService = new EventService(this.eventRepository);
assertThatThrownBy(() -> eventService.createNewEvent(halloween()))
.isInstanceOf(DuplicateEventException.class);
verify(eventRepository, times(1))
.findOne(halloween().asExample()); (4)
}
}
1 | Instruiert JUnit, die Tests mit Mockito auszuführen |
2 | Das ist notwendig, damit automatisch "Attrappen" zur Verfügung stehen. |
3 | Stellt das Szenario her: Die Attrappe des Repositorys wird so konfiguriert, dass die Suche nach dem "Halloween"-Event immer einen Treffer liefert. |
4 | Die Attrappe ist nicht nur Platzhalter für einen anderen Kollaborateur, sondern wird auch zur Überprüfung des Service-Code genutzt: Wurde die Methode findOne tatsächlich aufgerufen? |
Spring ist unter anderem eine Implementierung eines "Context- and Dependency-Injection"-Containers. Die Kollaborateure des Services — im Beispiel nur das Repository — werden von außen hereingereicht. Würde das Repository über einen Aufruf von new
im Service selber erzeugt, könnte ein Test wie oben nur über einen erheblichen Aufwand realisiert werden. Dependency-Injection über Attribute ist mit Hinblick auf Testen auch nicht zielführend und teilweise schädlich: Wie wird sichergestellt, dass alle für einen Test benötigten Kollaborateure auch vorhanden sind? Wie werden Kollaborateure ohne Setter-Methoden gesetzt?
Der Spring DI-Container nimmt Ihnen die Arbeit ab, Infrastruktur für Dependency-Injection selber zuschreiben. Die Erzeugung von Kollaborateuren ist vollständig von ihrer Benutzung entkoppelt. Da Spring keine Reflection-Hacks einsetzt sondern "nur" den Konstruktor des Service nutzt, erhalten Sie eine Klasse, die ohne Hacks testbar bleibt. Tatsächlich liegt auch in Listing 6 noch ein echter Unit-Test vor, da anstelle des Repositorys nur eine Attrappe genutzt wird und zu keinem Zeitpunkt der Spring-Container benötigt wird.
5.3. Gezielte Tests technischer Schichten
Spring Boot stellt Ihnen unter dem Begriff "Test-Slices" eine Möglichkeit zur Verfügung, gezielt technische Schichten einer Anwendung im Kontext des laufenden Spring Containers zu testen. Technische Schichten sind zum Beispiel Datenbankzugriff oder der Weblayer. Für Ihren Test hat das den großen Vorteil, dass er schlanker sein kann. Möchten Sie gezielt eine REST-API testen, können Sie oftmals auf eine echte Datenbank oder andere unterstützende Dienste im Hintergrund verzichten. Gegeben sei die API in Listing 7. Sie basiert auf Spring Web MVC, das dominante Programmiermodell basiert auf Annotationen. Im Beispiel besagen sie, dass unter der URL /api/events/2017-12-24/Weihnachten
ein Event mit seinen Eigenschaften abrufbar sein soll.
@RestController (1)
@RequestMapping("/api/events") (2)
public class EventsApi {
private final EventService eventService;
@GetMapping("/{heldOn}/{name}") (3)
public EventResource event(
@PathVariable @DateTimeFormat(iso = ISO.DATE) (4)
final LocalDate heldOn,
@PathVariable final String name
) {
return this.eventService
.getEvent(heldOn, name)
.map(eventResourceAssembler::toResource)
.orElseThrow(NoSuchEventException::new);
}
}
1 | Markiert die Klasse als Rest-Endpunkt. |
2 | Gibt den Pfad /api/events als Basispfad für alle weiteren URLs dieser Klasse vor. |
3 | Bildet diese Methode auf einen Pfad unterhalb von /api/events ab, der durch zwei Pfadvariablen, heldOn und name parametrisiert ist. |
4 | @PathVariable ordnet die Methodenparameter der Variablen der URL zu. |
Die API benötigt dazu den Event Service. An dieser Stelle möchte ich sicherstellen, dass die Abbildung der Funktion auf URLs und die Serializierung der Event-Daten korrekt funktioniert. Das Ziel ist, die Integration der Komponenten EventsApi
und EventService
mit dem Spring Web Framework zu testen. Spring Boot stellt Ihnen für diese Schicht @WebMvcTest
zur Verfügung. Listing 8 zeigt die Verwendung:
@RunWith(SpringRunner.class) (1)
@WebMvcTest(controllers = EventsApi.class) (2)
@AutoConfigureRestDocs (3)
public class EventsApiTest {
@MockBean (4)
private EventService eventService;
@Before
public void initializeMocks() {
final Event event1 = new Event(LocalDate.now(), "Event-1");
when(eventService.getEvent(event1.getHeldOn(), event1.getName()))
.thenReturn(Optional.of(event1));
}
@Test
public void eventShouldWork() throws Exception {
this.mockMvc
.perform(
get("/api/events/{heldOn}/{name}",
LocalDate.now(), "Event-1"
).accept(HAL_JSON)) (5)
.andExpect(status().isOk()) (6)
.andDo(document("get-event", (7)
responseFields(
fieldWithPath("heldOn").description("The date of this event."),
fieldWithPath("name").description("The name of this event."),
fieldWithPath("numberOfFreeSeats")
.description("Number of free seats left."),
subsectionWithPath("_links")
.description("Links to other resources")
)));
}
}
1 | Hier wird ein spezieller Runner benötigt, der den Spring-Kontext startet. |
2 | Damit der Test schneller startet, soll er nur die in Listing 7 gezeigte Klasse beinhalten. |
3 | Hiermit wird Spring REST Docs aktiviert, eine Möglichkeit, während des Tests automatisch eine API Dokumentation zu erzeugen. |
4 | Anweisung, den EventService als Mock bereitzustellen. Dieser Mock wird in der nachfolgenden Methode konfiguriert, bei bestimmten Eingaben immer ein bestimmtes Ergebnis zu liefern |
5 | Aufruf der API |
6 | Ausdruck des erwarteten Ergebnis |
7 | Hier wird eine Aktion formuliert, die nach Erfüllung aller Erwartungen ausgeführt wird. Struktur der Rückgabe wird dokumentiert. Die Dokumentation ist gleichzeitig Ausdruck einer weiteren Erwartung: Das JSON-Dokument muss die Felder heldOn , name und so weiter erwartet |
Der Test in Listing 8 ist durchaus komplex: Es wird ein Spring-Kontext und das Web Framework gestartet; dennoch ist er lesbar und die Erwartungen sind klar erkennbar. Durch die Integration mit Spring REST Docs generiert er darüber hinaus eine dynamische Dokumentation der API, die aktiver Teil des Tests ist: Fallen die hier beschriebenen Felder auf einmal weg, bricht der Test. Die Dokumentation kann in verschiedenen Formaten generiert werden. Das Beispielprojekt nutzt das AsciiDoc-Format, der Auszug in Table 1 ist Teil des Builds.
Path | Type | Description |
---|---|---|
|
|
The date of this event. |
|
|
The name of this event. |
|
|
Number of free seats left. |
|
|
Links to other resources |
5.4. Integrationstests
Bis hierhin wurden Unittests unterschiedlicher Schwierigkeit durchgeführt. Es musste umso mehr Infrastruktur bereitgestellt werden, je näher an der Anwendungsschicht getestet wurde. Trotzdem wurde der eigentliche Kontext des Event-Service nicht verlassen. Der erste Integrationstests mit Komponenten außerhalb des Spring-Kontexts wird mit der Deklaration einer Datenbankabfrage in Listing 9 benötigt. Diese Abfrage wurde zwar in JPQL, der Java Persistence Query Language aufgeschrieben und sollte damit einigermaßen portabel sein, aber Sie müssen trotzdem sicherstellen, dass sich keine Tippfehler eingeschlichen haben oder die geplante Zieldatenbank Ihre Abfrage umsetzen kann.
@Query("Select e from Event e "
+ " where e.status = 'open' "
+ " and e.heldOn > current_date"
+ " order by e.heldOn asc"
)
List<Event> findAllOpenEvents();
Es ist sinnvoll, Integrationstests von Unittests zu trennen; zum Beispiel erkennbar am Namen, besser noch durch separate Sourcen. Mit Gradle und dem eingebauten JUnit-Plugin ist das für eine Spring-Boot-Anwendung schnell und nachvollziebar gemacht, wie Listing 10 zeigt.
sourceSets { (1)
integrationTest {
java {
srcDir 'src/integrationTest/java'
}
resources {
srcDir 'src/integrationTest/resources'
}
compileClasspath += sourceSets.test.compileClasspath
runtimeClasspath += sourceSets.test.runtimeClasspath
}
}
task integrationTest(type: Test) { (2)
group = LifecycleBasePlugin.VERIFICATION_GROUP
systemProperty "spring.profiles.active", "it" (3)
testClassesDirs = sourceSets.integrationTest.output.classesDirs
classpath = sourceSets.integrationTest.runtimeClasspath
}
1 | Definition einer weiteren Menge von Quellen mit Namen integrationTest |
2 | Definition eines Tasks, der vom Test-Task erbt, aber auf anderen Quellen arbeitet |
3 | Aktivierung eines Spring-Profiles, um passende Konfigurationseigenschaften zu aktivieren |
Integrationstests gegen Datenbanken können heikel sein. Schwergewichtige Datenbanken wie eine Oracle-Datenbank nur für einen Test zu starten, kann dauern. Gegen In-Memory-Datenbanken zu testen, bildet nicht die Realität ab. Eine Alternative ist die Generierung unterschiedlicher Schemas für Testzwecke. Das Beispielprojekt des Artikels geht den konsequenten Weg und startet die Zieldatenbank, eine PostgreSQL-Instanz, während der Integrationstests per Docker. Die Datenbank wird mit den Mitteln von Spring Boot initialisiert und der vollständige Test ergibt sich in Listing 11.
@RunWith(SpringRunner.class)
@DataJpaTest
@ContextConfiguration(initializers = PortMappingInitializer.class)
public class EventRepositoryIT {
private static DockerComposeRule docker = DockerComposeRule.builder()
.file("src/integrationTest/resources/docker-compose.yml")
.waitingForService("it-database", PostgresHealthChecks::canConnectTo)
.build();
@ClassRule
public static TestRule exposePortMappings = RuleChain.outerRule(docker)
.around(new PropagateDockerRule(docker));
@Autowired
private EventRepository eventRepository;
@Test
public void someTest() {
final List<Event> openEvents =
this.eventRepository.findAllOpenEvents();
final Event expectedEvent
= new Event(LocalDate.now().plusDays(1), "Open Event");
assertThat(openEvents)
.containsExactly(expectedEvent)
.extracting(Event::getNumberOfFreeSeats)
.first()
.isEqualTo(19);
}
}
Durch den Einsatz einer klassenweiten JUnit-Regel, der Docker-Compose-Rule, kann Docker zusammen mit Docker Compose genutzt werden, um die benötigten, externen Systeme zu starten. Dieses System kann alle notwendigen Daten enthalten oder vom Spring-Kontext noch initialisiert werden.
In Listing 11 wird der Test-Slice @DataJpaTest
benutzt, der die Datenbankschicht startet. Möchten Sie auf Funktionalitäten Ihrer neuen Anwendung von externen Systemen zugreifen, so nutzen Sie @SpringBootTest
. Damit fahren Sie die Spring-Boot-Anwendung vollständig während eines Tests hoch. Eine weitere, empfehlenswerte Variante für einen vollständigen Systemtest ist, Ihre Anwendung und alle benötigten Services in einem Container zu starten und diese von außen durch einen dezidierten Dienst zu testen und so unter anderem Abhängigkeiten zwischen Integrationstests zu vermeiden.
5.5. Überprüfung der Testabdeckung
Als Testabdeckung wird das Verhältnis der getroffenen Aussagen eines Tests gegenüber den möglichen Aussagen bezeichnet. Dabei spielen unterschiedliche Kriterien wie Funktions-, Statement- und Zweigabdeckung oder auch Abdeckung von Bedingungen eine Rolle. Ein bekanntes Tool in der Java-Welt zur Messung der Testabdeckung ist ist JaCoCo. Listing 12 zeigt, dass JaCoCo mit nur wenigen Zeilen in Ihr Gradle Build File eingebaut werden kann.
plugins {
id "jacoco"
}
jacocoTestCoverageVerification {
executionData test, integrationTest
violationRules {
rule {
limit {
minimum = 0.5
}
}
}
}
jacocoTestReport {
executionData test, integrationTest
}
build.dependsOn jacocoTestCoverageVerification, jacocoTestReport
Definieren Sie ein Mindestmaß an Testabdeckung, das Sie nicht unterschreiten möchten, aber zwingen Sie Ihre Entwickler nicht, unrealistisch hohe Vorgaben einzuhalten. Die Kosten überschreiten den Nutzen schnell und die Versuchung ist groß, Code und Tests zu produzieren, die die Abdeckung in die Höhe treiben ohne inhaltlich zu testen. Die Etablierung eines Testfundaments und davon ausgehender Durchstich durch die Ebenen einer Architektur ist wichtiger als eine möglichst hohe Menge.
Die Änderung der Testabdeckung in Relation zu neuem Code ist oftmals eine bessere Metrik zur Beurteilung von Qualität als der absolute Wert der Abdeckung.
Betrachten Sie lieber gültige Eingabebereiche für Module und Grenzfälle, anstatt immer alle Pfade durch eine Methode zwanghaft durchlaufen zu wollen. Nehmen Sie Daten, die zu Fehlern geführt haben, in Ihre Tests auf. Um die Qualität ihrer Tests selber zu verbessern kann Mutationstesting sinnvoll sein. Dabei wird während der Ausführung von Tests der zu testende Code mutiert, so dass es zu Fehlern kommt. Werden diese Fehler von Ihren Tests nicht erkannt, müssen die Testfälle anders gewählt werden.
6. Fazit
6.1. Die Testpyramide
Der Autor Alister Scott stellte 2012 den Begriff der Testpyramide und das dazugehörige Antipattern, das Testing-Eishörnchen vor.
Unit-Tests sind — die richtige Herangehensweise vorausgesetzt — einfach zu erstellen und sollten zahlreich vorhanden sein. Integrationstest — in vielfältigen Ausprägungen (in Figure 1 als API Tests, Integrationstests zwischen Systemen oder als Tests zwischen Komponenten) — sind in der Regel aufwendiger, und eine der Königsdisziplinen sind automatisierte UI-Tests, darüber sind nur noch Click-Tests durch echte Benutzer angesiedelt. Die sind in der Regel einfach durchzuführen, dadurch nicht weniger teuer. Leider sieht es in der Realität oftmals eher so aus, dass die "Wolke" an der Spitze übermässig groß ist und die Pyramide umgekehrt wird. Es werden immer noch viele manuelle und damit langsame und teure Tests durchgeführt.
Versuchen Sie, das Fundament Ihrer Anwendung, die fachlichen Anforderungen, so klar wie möglich herauszuarbeiten und klassisch mit Unit-Tests zu verifizieren. Ob Sie dabei tatsächlich immer hundertprozentig testgetrieben vorgehen, sei dahin gestellt. Tests müssen Vertrauen schaffen, auf Basis dessen fallen Refactorings und Erweiterungen leicht. Ob Tests physikalisch vor dem zu testenden Code existiert, ist zweitrangig.
6.2. Weitere Tests und Herausforderungen
Gerade in Hinblick auf das Thema continous delivery, also der kontinuierlichen Auslieferung von Bausteinen eines Systems, kommen weitere Testarten ins Spiel. Eine continous delivery pipeline bringt eine Software durch verschiedene Phasen kontinuierlich in Produktion. Diese Phasen beinhalten Performance-, Akzeptanz-, Kapazitäts- und auch explorative Tests.
Mein Kollege Eberhard Wolff spricht in dieser Hinsicht von Unendlichem Vertrauen. Akzeptanztests schaffen vertrauen beim Kunden, ob die Software ihre Anforderungen erfüllt und können als Fundament einer weiteren Testpyramide betrachtet werden, die sich der Software sozusagen von der anderen Seite nähert. Wichtig ist allerdings, auch diese Tests soweit wie möglich zu automatisieren, um das Ziel zu erreichen, eine Software möglichst schnell und im Falle von Änderungen auch möglichst oft in Produktion zu bringen. Unstrittig ist, dass es noch schwieriger ist, Kunden bei der Entwicklung automatisierter Tests mit ins Boot zu holen, aber die sich daraus ergebenden Vorteile sind den Aufwand wert.
Schlussendlich stehen und fallen Konzepte mit den Menschen dahinter. Der Wert von Unit- und Integrationstests muss ebenso erkannt und gelebt werden wie der von automatisierten Akzeptanztests, damit etwas wie continous delivery erfolgreich in einer Organisation umgesetzt wird.
7. Über den Autor
Michael Simons arbeitet als Senior Consultant bei innoQ Deutschland. Er ist Mitglied des NetBeans Dream Team und Gründer der Euregio JUG. Michael schreibt in seinem Blog über Java, Spring und Softwarearchitektur. Michael ist Autor des im Januar 2018 erscheinenden Spring Boot Buches.
Auf Twitter unterwegs als @rotnroll666, unter anderem mit Java, Musik und den kleineren und größeren Problemen als Ehemann und Vater von 2 Kindern.
Sein Buch "Spring Boot — Moderne Softwareentwicklung im Spring-Ökosystem" ist 2018 im dpunkt.verlag erschienen. Es behandelt Spring Boot 2 und das neue, reaktive Programmierparadigma von Spring 5 ebenso wie Spring-Grundlagen und spricht damit erfahrene Spring-Entwickler wie auch Spring-Neulinge an.