Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Testdaten mit den Entwurfsmustern „Object Mother“ und „Builder“

Die Erstellung und Pflege von Testdaten ist eine der zeitaufwendigsten und tristesten Aktivitäten der Softwareentwicklung. Existierende Daten, beispielsweise aus einer Produktionsdatenbank, zu verwenden, erscheint als Mittel der Wahl, doch die damit verbundenen Nachteile werden oftmals vergessen oder ignoriert. Insbesondere Komponententests benötigten verlässliche, einfach anzupassende und konsistente Testdaten. Die Entwurfsmuster „Object Mother“ und „Builder“ adressieren diese Herausforderungen, indem sie ein skalierbares Framework zur Erzeugung und leichten Anpassbarkeit von Testobjekten bilden.
Author Image
Markus Höber

Author


  • 31.05.2019
  • Lesezeit: 7 Minuten
  • 19 Views

Was erlauben Testdaten?

Welcher Entwickler schaute noch nie auf einen leeren jUnit-Test und fragte sich, woher er all die benötigten Geschäftsobjekte für die Testausführung bekommt? Selbst eine vermeintlich simple Funktion wie allocatePassengerToFlight() besitzt mehrere miteinander verbundene Geschäftsobjekte, die vor der Testausführung entworfen und realisiert sein müssen.

Die Konstruktion der benötigten Objekte, unabhängig davon, ob sie mithilfe von Platzhaltern erzeugt oder aus einer Datenbank gelesen werden, ist zeitaufwendig und zieht häufig viel Pflege nach sich. Liegen benötigte Geschäftsobjekte nicht in der Verantwortung des eigenen Entwicklungsteams, so müssen sich die betroffenen Entwicklungsteams untereinander abstimmen. Typische Fragestellungen nach der Verantwortlichkeit der Erstellung, der Pflege und der fachlichen Bedeutung von Eigenschaften der dezentral angelegten Objekte sind fortan bei jeder Änderung an den Objekten zu beantworten.

Iterativ-inkrementelle Entwicklungsvorgehen, die meist ein organisches Wachstum von Datenmodellen mit sich bringen, setzen darauf, dass ein Sicherheitsnetz aus automatisierten Tests besteht, das bei jeder Änderung ausgeführt wird und schnell Rückmeldung gibt, ob die Änderungen einerseits funktionieren und andererseits keine unerwünschten Seiteneffekte hervorrufen. Dementsprechend besitzen echte Komponententests keine Abhängigkeiten zu anderen Komponenten und Frameworks. Auch eine In-Memory-Datenbank (IMDB) stellt eine andere Komponente dar. Wird sie verwendet, so spricht man von einem Integrationstest.

Eine weitere Herausforderung für Entwickler stellt das Schreiben von geradlinigen und wartbaren Tests dar. Einerseits sollen sie dem Muster Arrangieren (Arrange), Ausführen (Act) und Auswerten (Assert) folgen und andererseits das Single-Responsibility-Prinzip erfüllen. Die umfangreiche Erzeugung von Geschäftsobjekten im Test ist keine gute Idee, denn sie führt zu schwer verständlichen Tests und bringt häufig Redundanz mit sich.

Die beiden Entwurfsmuster „Object Mother“ und „Builder“ bieten in Kombination einen strukturierten und skalierbaren Ansatz zur Erzeugung und Anpassung von Objekten für Tests, ohne dabei auf Platzhalter-Frameworks, zum Beispiel Mockito oder jMockit, und IMDB, beispielsweise H2, angewiesen zu sein.

Von Müttern …

Das Entwurfsmuster „Object Mother“ stellt ein vollwertiges Geschäftsobjekt mit all seinen Attributen zur Verfügung und bietet Methoden zur Bindung von zusammenhängenden Geschäftsobjekten [Sch01]. Die Trennung von Konstruktion und Repräsentation führt zu einer Vereinfachung der Erzeugung von Testdaten und verringert Redundanzen in Testfällen. Jedoch benötigen Variationen von Geschäftsobjekten eigene Factory-Methoden zur Erzeugung, wodurch der Umfang einer Object-Mother-Klasse schnell steigt.

public static Flight createNewFlight() {
Flight flight = new Flight();
flight.setFlightId(1L);
flight.setFlightDeparture(createNewFlightDeparture());
flight.setFlightArrival(createNewFlightArrival());
flight.setFlightPrice(9.99);
flight.setFlightReservedSeats(0);
flight.setFlightAllocations(new ArrayList<>());
flight.attachFlightToAllocation(flight);
flight.setFlightRouteIdFk = new RouteId(1L);
flight.setFlightAircraftIdFk = new AircraftId(1L);
return flight;
}
Listing 1: Geschäftsobjekt Flight mit Object Mother erzeugen

Im Beispiel Listing 1 wird ein Geschäftsobjekt Flight erzeugt. Die Methode attachFlightToAllocation() (s. Listing 2) verknüpft ein Geschäftsobjekt Flight mit einem Geschäftsobjekt FlightAllocation. Der Flug ist hierbei das Eltern- beziehungsweise Wurzelelement und die FlightAllocations sind die Kind- beziehungsweise Unterelemente, die in Form einer Liste am Flug hängen.

public static void attachFlightToAllocation(Flight flight) {
flightAllocation flightAllocation = createNewFlightAllocation();
flightAllocation.setFlightIdFk(flight.getFlightId());
flight.getFlightAllocations().add(
 flightAllocation.getFlightAllocationId());
}
Listing 2: Flight mit Allocation assoziieren

Dabei wird ein neues, vollständiges Geschäftsobjekt FlightAllocation durch Aufruf der Methode createNewFlightAllocation() erzeugt. Die Beziehung der Geschäftsobjekte bei der Erzeugung wird mit diesem Ansatz aufgelöst. 
Eine Variation des Geschäftsobjekts Flight erfolgt durch Wiederverwendung des Standard-Geschäftsobjekts und der Anwendung von Änderungsmethoden (Setter). Die Variation cancelledFlight (s. Listing 3) entsteht so durch Anpassung der Standard-Repräsentation des Geschäftsobjekts Flight.

public Flight createCancelledFlight() {
Flight flight = createNewFlight();
flight.setFlightState(FlightState.CANCELLED);
return flight;
}
Listing 3: Variation cancelledFlight erzeugen

Testfälle erzeugen die benötigte Repräsentation der Geschäftsobjekte (Testdaten) durch Aufruf der jeweiligen Factory-Methoden aus der „Object Mother“-Klasse (s. Listing 4). Die konkrete Repräsentation eines Fluges beispielsweise erfolgt durch Aufruf von createFlight.

public void testFlightWithAllocation() throws Exception {
Flight flight = Flight.Mother().createNewFlight();
// ...and on with the test
}
Listing 4: Object Mother Testfall

Testdaten lassen sich mit diesem Entwurfsmuster einfach bereitstellen und Redundanzen weitestgehend vermeiden. Jedoch können die Testdaten nur mit Settern an die Bedürfnisse angepasst werden. Mit zunehmenden Anforderungen an Ausprägungen der Geschäftsobjekte steigt außerdem die Anzahl an Factory-Methoden. Beides ist häufig unerwünscht, weil es die Komplexität erhöht und die Wartbarkeit verringert.

… und Erbauern

Das Entwurfsmuster „Builder“ verwendet unabhängige Teilschritte zur Erzeugung komplexer Geschäftsobjekte [GoF01]. Auch mit diesem Ansatz sind die Erzeugung und die Repräsentation voneinander getrennt. Jedoch ist die unvollständige Erzeugung von Geschäftsobjekten möglich. Das Beispiel in Listing 5 zeigt die Erzeugung eines vollständigen Geschäftsobjekts Flight bei Anwendung des Entwurfsmusters Builder.

private Flight(Builder builder) {
this.flightId = builder.flightId;
this.flightAircraftIdFk = builder.flightAircraftIdFk;
this.flightRouteIdFk = builder.flightRouteIdFk;
this.flightDeparture = builder.flightDeparture;
this.flightArrival = builder.flightArrival;
this.flightPrice = builder.flightPrice;
this.flightReservedSeats = builder.flightReservedSeats;
this.flightAllocations = builder.flightAllocations;
this.flightState = builder.flightState;
}
Listing 5: Geschäftsobjekt Flight mit Builder erzeugen

Der sogenannte Builder verwendet Methoden, die die Attribute des Geschäftsobjekts vor der Erzeugung setzen. Die abschließende Methode build() erzeugt schließlich das unveränderliche Geschäftsobjekt mit den zuvor gesetzten Attributen. Beispielweise wird die FlightID des Geschäftsobjekts Flight mit der Methode with-FlightId() gesetzt (s. Listing 6).

public Builder withFlightId(FlightId flightId) {
this.flightId = flightId;
return this;
}
Listing 6: FlightId für FlightBuilder setzen

Testfälle erzeugen die Testdaten durch Aufruf der einzelnen Builder-Methoden, die die Teilschritte der Konstruktion darstellen. Attribute, die nicht per Methode gesetzt wurden und keinen Standardwert besitzen, sind dadurch null. Ist das Fluent-API wie im Beispiel implementiert, so lässt sich ein kaskadierter Aufruf verwenden (s. Listing 7).

public void testFlightWithAllocation() {
// .. declaration skipped
Flight flight = new Flight.Builder().withFlightId(new FlightId(1L))
 .withDeparture(flightDeparture)
 .withArrival(flightArrival)
 .withFlightPrice(new FlightPrice(9.99))
 .withFlightAllocation(flightAllocation)
 .withAircraftId(new AircraftId(1L))
 .withRouteId(new RouteId(1L))
 .withFlightState(FlightState.BOOKABLE)
 .build();
// ... and on with the test
}
Listing 7: Builder Testfall

Auf diese Weise sind die erzeugten Testdaten aus fachlicher Sicht nachvollziehbar und können mit geringem Aufwand angepasst werden. Jedoch entstehen schnell Redundanzen, da das Entwurfsmuster „Builder“ keine vorgefertigten Ausprägungen der Geschäftsobjekte realisiert. Eine nachträgliche Veränderung mit Settern ist nicht möglich. Die Vermeidung von Settern reduziert die Wahrscheinlichkeit, ein anämisches Datenmodell [Fow03] zu erzeugen, und fördert die Kapselung der Geschäftsobjekte.

Geschickt kombiniert

Beide Entwurfsmuster trennen die Erzeugung und Repräsentation von Geschäftsobjekten. „Object Mother“ legt seinen Schwerpunkt auf die Erzeugung vollständiger Geschäftsobjekte, die durch Setter veränderlich sind. „Builder“ legt seinen Schwerpunkt auf die schrittweise Konstruktion von Geschäftsobjekten, die unveränderlich sind und somit keine Setter benötigen.

Die Vorteile beider Entwurfsmuster lassen sich kombinieren. Eine angemessene Anzahl an vorgefertigten, vollständigen Geschäftsobjekten wird dadurch bereitgestellt und die nachträgliche, individuelle Anpassung an spezielle Bedürfnisse ist möglich. Die Erzeugung eines vollständigen Geschäftsobjekts Flight unter Anwendung beider Entwurfsmuster zeigt Listing 8.

public MBuilder aFlight() {
this.flightId = new FlightId(1L);
this.flightAircraftIdFk = new AircraftId(1L);
this.flightRouteIdFk = new RouteId(1L);
this.flightDeparture = new FlightDeparture(LocalDateTime.of
 (LocalDate.of(2019, 1, 3), LocalTime.of(7, 15)));
this.flightArrival = new FlightArrival(LocalDateTime.of(LocalDate.of
 (2019, 1, 3), LocalTime.of(9, 45)));
this.flightPrice = new FlightPrice(17.00);
this.flightAllocations = new ArrayList<>();
flightAllocations. add(
 new FlightAllocation.Builder().aFlightAllocation().build());
this.flightReservedSeats = new FlightReservedSeats(0);
this.flightState = FlightState.BOOKABLE;
return this;
}
Listing 8: Geschäftsobjekt Flight mit Object Mother & Builder erzeugen

Eine FlightAllocation als Kindelement von Flight besitzt die in Listing 9 gezeigte Implementierung zur Erzeugung einer Standard-Ausprägung, die das Elternelement Flight zur Erzeugung verwendet.

public MBuilder aFlightAllocation() {
this.allocationId = new AllocationId(1L);
this.flightIdFk = new FlightId(1L);
this.passengerIdList = new ArrayList<>();
passengerIdList.add(new PassengerId(1L));
this.numberOfReservedSeats = new NumberOfSeats(1);
return this;
}
Listing 9: Geschäftsobjekt Allocation mit Object Mother & Builder

Testfälle erzeugen die Testdaten durch Aufruf der Builder-Methoden, beginnend mit einer vorgefertigten Ausprägung des Geschäftsobjekts. Optional können bestimmte Attribute vor der Erzeugung angepasst werden. Listing 10 zeigt die Erzeugung eines Flight mit zwei FlightAllocations.

public void testFlightWithTwoAllocations() {
FlightAllocation flightAllocation =
 new FlightAllocation.MBuilder().aFlightAllocation()
 .withAllocationId(new AllocationId(2L)).build();
Flight flight = new Flight.MBuilder().aFlight()
 .withFlightAllocation(flightAllocation).build();
// ... and on with the test
}
Listing 10: Object Mother & Builder Testfall

Hiermit lassen sich valide, synthetische Geschäftsobjekte als Testdaten erzeugen. Die individuelle Anpassung ist nachvollziehbar und Redundanzen sind reduziert. Die erzeugten Geschäftsobjekte benötigen Setter und sind dadurch nur durch Geschäftslogik veränderlich, wenn überhaupt.

Abschließende Worte

Die Erzeugung von Testdaten in Form von vollständigen Geschäftsobjekten lässt sich mit der kombinierten Anwendung der Entwurfsmuster „Object Mother“ und „Builder“ harmonisieren. Die wesentlichen Vorteile sind die vereinfachte und vereinheitlichte Erzeugung von Geschäftsobjekten, die verbesserte Wartbarkeit aufgrund der Trennung von Erzeugung und Repräsentation der Geschäftsobjekte und die erhöhte Lesbarkeit von Testfällen.

Der augenscheinliche Nachteil ist der erhöhte Aufwand für die Implementierung der benötigten Klassen und Methoden. Jedoch ist die Implementierung von Testfällen dadurch weniger aufwendig, weil diese nicht die Konstruktion der Geschäftsobjekte immer wieder aufs Neue implementieren. Entwickler werden dadurch ermutigt, mehr Testfälle zu schreiben. Sie können ihre gewohnte „Denk mehr, schreib weniger“-Mentalität auch für Testfälle verwenden. Analysten können Entwickler besser unterstützen, weil die Erzeugung von Geschäftsobjekten verständlicher ist.

Literatur und Links

[Fow03]
M. Fowler, Anemic Domain Model, 2003, 
https://www.martinfowler.com/bliki/AnemicDomainModel.html

[GoF01]
E. Gamma u. a., Entwurfsmuster – Elemente wiederverwendbarer objektorientierter Software, Addison-Wesley, 2001

[Sch01]
P. Schuh, S. Punke, ObjectMother – Easing Test Object Creation in XP, 2001,
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.18.4710&rep=rep1&type=pdf

. . .

Author Image

Markus Höber

Author
Zu Inhalten
Markus Höber ist Senior Quality Manager bei der adesso AG. Sein Herz schlägt für die agile Kultur, qualitätsgetriebene Softwareentwicklung und die neuesten technologischen Entwicklungen. Bei seinen Kunden konzipiert und unterstützt er die Einführung von agilem Softwaretesten, begleitet die Digitale Transition und evaluiert und etabliert Testwerkzeuge.

Artikel teilen