Domain- und Test-driven Development mit Spring Boot 2 Softwarearchitektur

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.

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.

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ß.

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 und Spring Boot

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"
}
Listing 1. build.gradle

Durch nur eine Deklaration erhalten Sie:

  • JUnit
  • Springs Test-Support
  • Mockito
  • AssertJ, eine Library, die es Ihnen ermöglicht, sehr klare und ausdrucksstarke Zusicherungen (Assertions) zu erwarteten Ergebnissen zu formulieren

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, 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;
    }
}
Listing 2. Event.java
  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.

Die Tests

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();
    }
}
Listing 3. Unit Tests von Pre- und Postconditions
  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.

Warum nicht JUnit 5?

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);
}
Listing 4. EventRepository.java

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);
    }
}
Listing 5. Speichern von Events

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>
    }
}
Listing 6. Test des Service
  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.

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);
    }
}
Listing 7. Events API
  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")
                )));
    }
}
Listing 8. Test der API
  1. Hier wird ein spezieller Runner benötigt, der den Spring-Kontext startet.
  2. Damit der Test schneller startet, soll er nur die in Listing7 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 Ergebnissen
  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 zur Generierung von Dokumentationsfragmenten, die in einer ausführlichereren Dokumentation eingebunden werden können.

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();
Listing 9. Events API

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
}
Listing 10. Separate Quellen für Integrationstests mit Gradle
  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 Tests 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);
    }
}
Listing 11. Integrationstests gegenüber einer Datenbank

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.

Was ist Docker Compose?

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.

Ü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
Listing 12. Überprüfung der Testabdeckung mit JaCoCo

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.

Fazit

Die Testpyramide

Der Autor Alister Scott stellte 2012 den Begriff der Testpyramide und das dazugehörige Antipattern, das Testing-Eishörnchen vor.

Die Testpyramide und ihr Antipattern

Unit-Tests sind — die richtige Herangehensweise vorausgesetzt — einfach zu erstellen und sollten zahlreich vorhanden sein. Integrationstest — in vielfältigen Ausprägungen (in figure_title 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.

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.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie Vereinbarung zustimmen. Mehr lesen