Für uns Softwareentwickler ist der ultimative Endgegner immer die Komplexität. Wir haben zahlreiche, teils ziemlich mächtige Waffen gesammelt, um in diesen Kämpfen bestehen zu können: Dinge wie Modularisierung, Abstraktion, Lean Development, iteratives Vorgehen, CI/CD usw. dienen alle dazu, dieses Monster im Zaum zu halten. Seltsamerweise vergessen wir aber an manchen Stellen, diese Waffen dann auch tatsächlich einzusetzen! Gerade Tests verdienen oft sehr viel mehr Unterstützung in diesem Kampf, denn auch sie haben die starke Tendenz, schnell sehr komplex zu werden; insbesondere das berüchtigte Copy-and-Paste wird da oft viel zu leichtfertig praktiziert. Was liegt also näher, auch hier Lösungen zu finden, die neuen Teammitgliedern (oder auch unserem zukünftigen Ich) helfen, Tests schnell begreifen zu können, und bei Refactorings nicht immer hunderte von Tests mühsam einzeln anpassen zu müssen; was dann dazu führt, dass sinnvolle Refactorings gar nicht erst gemacht werden.
Soweit die Theorie; und die ist vermutlich relativ unstrittig. Werden wir also mal etwas konkreter: Ein mächtiges Zauberritual gegen die Komplexität von Tests sind die sog. Fixture-Builder. Das ist kein Thema auf „Hello-World”-Niveau, daher will ich euch hier langsam und in kleinen, sicheren Schritten heranführen, und zwar auf Code-Ebene in Java, was vermutlich von den meisten von euch einfach zu lesen ist.
Inhalt
- Kunde, Produkt, Bestellung
- Das eigentliche Problem
- Entfernte Datenhaltung
- Fixture Interface
- Microservices
- Und
then
? - tl;dr
Kunde, Produkt, Bestellung
Wir brauchen ein Beispiel, das tatsächliche Komplexitätsschmerzen verursacht, aber immer noch in einem Text nachvollziehbar ist, den man in einem Rutsch lesen kann. Damit ich euch nicht zu lange mit der Erklärung der Fachlichkeit ablenke, wähle ich ein ziemlich generisches und vereinfachtes Beispiel: Eine Preisberechnung mit drei Hauptentitäten:
- Produkt mit Preis.
- Kunde mit Rabatt-Prozentsatz.
- Bestellung mit Kunde und Positionen aus Anzahl und Produkt.
Um die Gesamtkosten einer Bestellung zu berechnen, muss man also für jede Bestellposition den Produktpreis mit der Anzahl multiplizieren, dann alle Positionen summieren, um schließlich den Rabatt-Prozentsatz abzuziehen.
Ein erster Test
Wir benutzen hier für die Erstellung der Testdaten einen Builder, den man mit Lombok mit ein paar Annotationen bequem bekommen kann. Trotzdem ist die Erstellung einer Bestellung auch nur mit zwei Positionen doch einiges an Code; selbst in einem Unit-Test. Zu anderen Testformen kommen wir später.
1class OrderUnitTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 var order = Order.builder()
5 .id(1)
6 .date(LocalDate.ofEpochDay(2))
7 .customer(Customer.builder()
8 .id(3)
9 .name("Customer-Name-4")
10 .discount(5)
11 .build())
12 .line(OrderLine.builder()
13 .id(6)
14 .amount(7)
15 .product(Product.builder()
16 .id(8)
17 .name("Product-Name-9")
18 .price(10)
19 .build())
20 .build())
21 .line(OrderLine.builder()
22 .id(11)
23 .amount(12)
24 .product(Product.builder()
25 .id(13)
26 .name("Product-Name-14")
27 .price(15)
28 .build())
29 .build())
30 .build();
31
32 var sum = order.getSum();
33
34 then(sum).isEqualTo(237);
35 }
36}
Einige der Felder, bspw. der Produktname, sind für diesen konkreten Test zwar nicht relevant, könnten aber Pflichtfelder sein – oder im Projektverlauf irgendwann werden, sodass wir das dann in allen Tests nachziehen müssten. Daher verwenden wir besser gleich überall Dummy-Werte. Außerdem versuchen wir, möglichst immer unterschiedliche Werte zu verwenden, damit Verwechslungen leichter auffallen. Bspw. wäre es blöd, wenn wir einen Bug haben, weil wir für den Preis einer Position den Produktpreis mit der Id statt der Anzahl multiplizieren, das aber in unseren Tests nicht bemerken, weil beide gleich sind! Um da den Überblick zu behalten, zählen wir hier einfach hoch. Eindeutige Werte haben übrigens auch Vorteile beim Debugging, denn wenn irgendwo ein Wert auftaucht, kann ich meistens einigermaßen leicht herausfinden, wo er genau herkommt.
Ein zweiter Test
Wenn wir ganz analog einen zweiten Test ohne Rabatt schreiben, sieht das erstmal so aus:
1class OrderUnitTest {
2 @Test
3 void shouldSumPriceWithoutDiscount() {
4 var order = Order.builder()
5 .id(1)
6 .date(LocalDate.ofEpochDay(2))
7 .customer(Customer.builder()
8 .id(3)
9 .name("Customer-Name-4")
10 .build())
11 .line(OrderLine.builder()
12 .id(6)
13 .amount(7)
14 .product(Product.builder()
15 .id(8)
16 .name("Product-Name-9")
17 .price(10)
18 .build())
19 .build())
20 .line(OrderLine.builder()
21 .id(11)
22 .amount(12)
23 .product(Product.builder()
24 .id(13)
25 .name("Product-Name-14")
26 .price(15)
27 .build())
28 .build())
29 .build();
30
31 var sum = order.getSum();
32
33 then(sum).isEqualTo(250);
34 }
35}
Im Wesentlichen einfach Kopierpaste; nur, dass wir hier beim Customer den Rabatt-Prozentsatz nicht setzen, er also bei 0 bleibt.
Schon innerhalb eines Tests erkennt man kaum die relevanten Details; noch schwieriger ist es aber, zu erkennen, was der Unterschied zwischen dem ersten und dem zweiten Test ist. Das WithoutDiscount
im Methodennamen gibt darauf einen kleinen Hinweis, und wenn man das System gut genug kennt, ahnt man schon, wo der Unterschied in diesem Code-Wimmelbild einfach stecken muss. Wenn man aber das System gerade erst kennenlernt, dann ist das verdammt hart. Derartige Setup-Buchstabensalate (mit oft weit mehr als 10 Zeilen) hab' ich jetzt schon öfters in verschiedenen Teams und Projekten gesehen. Damit zu arbeiten ist anstrengend und wenn man es eigentlich besser weiß, dann ist es irgendwie auch arrogant von den erfahrenen Alteingesessenen gegenüber allen Projektneulingen.
Die beiden Produkte sind in beiden Tests genau die gleichen. Es liegt also nahe, dafür Konstanten verwenden, um den Code zu kürzen:
1class OrderUnitTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 var order = Order.builder()
5 .id(1)
6 .date(LocalDate.ofEpochDay(2))
7 .customer(Customer.builder()
8 .id(3)
9 .name("Customer-Name-4")
10 .discount(5)
11 .build())
12 .line(OrderLine.builder()
13 .id(6)
14 .amount(7)
15 .product(PRODUCT_1)
16 .build())
17 .line(OrderLine.builder()
18 .id(11)
19 .amount(12)
20 .product(PRODUCT_2)
21 .build())
22 .build();
23
24 var sum = order.getSum();
25
26 then(sum).isEqualTo(151);
27 }
28
29 @Test
30 void shouldSumPriceWithoutDiscount() {
31 // ... ganz analog
32 }
33
34 private static final Product PRODUCT_1 = Product.builder()
35 .id(8)
36 .name("Product-Name-9")
37 .price(10)
38 .build();
39 private static final Product PRODUCT_2 = Product.builder()
40 .id(13)
41 .name("Product-Name-14")
42 .price(15)
43 .build();
44}
Diese Konstanten kann man schon als Fixture bezeichnen, zu Deutsch „feste (Wohnungs-)Einrichtung/Installation”, also ein Teil der Umgebung, in der die Tests dann bequem laufen können. Ich stelle mir das so vor, dass das die Stromleitungen, Armaturen, usw. sind, die in der Mietwohnung fest installiert sind, bevor der Test einzieht und sie dann einfach als gegeben nutzt, anstatt sie selbst erst installieren zu müssen.
Wenn es viele Konstanten werden, die dann auch noch von verschiedenen Testklassen verwendet werden, können wir sie in eine separate Klasse verschieben, bspw. TestData
oder sogar TestProducts
. Die numerischen Werte weiterhin streng sequentiell hochzuzählen, wird schnell immer mühsamer. Man kann dann bspw. in Zehnerschritten zählen, um Zwischenwerte für weitere Daten zur Verfügung zu haben. Oder pro Entität ab einem bestimmten Offset zählen, bspw. alle Daten für Produkte starten bei 100.000, währen wir bei Kunden mit 200.000 starten. Oder einen Zufallszahlengenerator verwenden. Oder. Oder. Oder. Egal wie, das ist einfach alles ziemlich fummelig. Dazu kommen wir aber noch.
Man muss bei Testkonstanten höllisch gut aufpassen, dass man sie niemals verändert. Bspw. sollte man keine Konstante CUSTOMER
einführen und dann im zweiten Test den Rabatt-Prozentsatz auf 0 setzen. Gemeinerweise fällt oft erstmal gar nichts auf, weil die Tests trotzdem grün bleiben können. Aus irgendeinem unersichtlichen Grund können sie dann aber viel später plötzlich oder – noch schlimmer – sporadisch fehlschlagen; bspw. nach einem Update der verwendeten Java-Version. Nach so einem Fehler will man nicht suchen müssen!
Aber wie kommt es zu diesem unangenehmen Verhalten? Die Reihenfolge, in der Tests ausgeführt werden, ist in der JVM normalerweise einfach undefiniert; d.h. sie können lange Zeit in der gleichen Reihenfolge ausgeführt werden und grün sein. Durch das Verändern der „Konstante” hat man aber eine verborgene Abhängigkeit zwischen den Tests eingebaut. Wenn die Tests dann in einer anderen Reihenfolge ausgeführt werden, schlagen sie fehl.
Solche Abhängigkeiten sind in Wirklichkeit meistens sehr viel subtiler versteckt als hier. Beispielsweise wird die „Konstante” verschachtelt in einer Methode verwendet und davon zurückgeliefert. Der Aufrufer kann also gar nicht so leicht erahnen, dass es ein Problem sein könnte, die Daten zu verändern. Man sollte also Konstanten am besten wirklich nur dann verwenden, wenn der Wert auch tatsächlich vollständig immutable ist, d.h. gar nicht verändert werden kann. Konstanten sind eine Shared Fixture, und der Nachmieter wird über Veränderungen an den Armaturen durch den Vormieter wenig erfreut sein.
Das eigentliche Problem
Durch die Einführung der Konstanten haben wir aber ein noch viel schwerwiegenderes Problem verschlimmert: Man kann jetzt sogar noch schlechter direkt sehen, welche Testdaten für den Test eigentlich relevant sind und welche nicht. Es gibt im Test nicht mehr nur Daten, die irrelevant sind (bspw. der Kundenname); wir haben sogar relevante Daten (den Produktpreis) in den Konstanten versteckt.
Das muss besser gehen.
Builder-Methoden
Eine erste Verbesserung ist es, statt Konstanten statische Methoden zu verwenden. Denen kann man dann die für einen Test tatsächlich relevanten Daten als Parameter übergeben, bspw. den Rabatt-Prozentsatz für den Kunden.
1class TestData {
2 static Customer someCustomerWithDiscount(int discount) {
3 return Customer.builder()
4 .id(3)
5 .name("Customer-Name-4")
6 .discount(discount)
7 .build();
8 }
9}
10
11class OrderUnitTest {
12 @Test
13 void shouldSumPriceWithDiscount() {
14 var order = Order.builder()
15 // ...
16 .customer(someCustomerWithDiscount(5))
17 // ...
18 .build();
19
20 var sum = order.getSum();
21
22 then(sum).isEqualTo(237);
23 }
24}
Ich hab' die Methode mit dem Prefix some
benannt, weil die Bedeutung „beliebig” wichtig ist, denn alle anderen Daten sind für den Test irrelevant. Wir können dafür, wie oben angedeutet, Daten verwenden, die fachlich sinnvoll und üblich sind (was den ggf. vernachlässigbaren Nachteil hat, dass der gleiche Wert an verschiedenen Stellen verwendet werden könnte; das muss man abwägen). Wir könnten uns aber auch das ganze manuelle Hochzählen oder Ausdenken von Werten sparen und sie bspw. über eine Zählvariable setzen. Am Beispiel Customer
:
1class TestData {
2 private static int nextInt = 100;
3
4 static int someInt() {
5 return nextInt++;
6 }
7
8 static Customer someCustomerWithDiscount(int discount) {
9 return Customer.builder()
10 .id(someInt())
11 .name("Customer-Name-" + someInt())
12 .discount(discount)
13 .build();
14 }
15}
Ich initialisiere hier nextInt
auf 100, weil gerade die 0 und die 1 oft eine besondere Semantik haben. Außerdem kann man so die unwichtigen Werte leichter erkennen.
Mit diesem someInt
können wir auch direkt die anderen IDs erzeugen; das Bestelldatum können wir durch eine darauf basierende Methode someLocalDate
und die beiden Produkte durch eine einzige Methode someProductAt(int price)
generieren:
1class TestData {
2 // ...
3
4 static LocalDate someLocalDate() {
5 return LocalDate.ofEpochDay(someInt());
6 }
7
8 static Product someProductAt(int price) {
9 return Product.builder()
10 .id(someInt())
11 .name("Product-Name-" + someInt())
12 .price(price)
13 .build();
14 }
15}
16
17class OrderUnitTest {
18 @Test
19 void shouldSumPriceWithDiscount() {
20 var order = Order.builder()
21 .id(someInt())
22 .date(someLocalDate())
23 .customer(someCustomerWithDiscount(5))
24 .line(OrderLine.builder()
25 .id(someInt())
26 .amount(7)
27 .product(someProductAt(10))
28 .build())
29 .line(OrderLine.builder()
30 .id(someInt())
31 .amount(12)
32 .product(someProductAt(15))
33 .build())
34 .build();
35
36 var sum = order.getSum();
37
38 then(sum).isEqualTo(237);
39 }
40}
Das erste wichtige Ziel ist also schon erreicht: Alle Daten, die für unseren Testfall relevant sind, stehen jetzt direkt im Testcode; und keine anderen.
Schachteln
Das Bauen einer OrderLine
zieht sich aber immer noch über immerhin fünf Zeilen hin. Außerdem enthält der Code für diesen Test irrelevante Aufrufe: .id(someInt())
, u.a. Das ist an sich erstmal nicht weiter wirklich schlimm; aber wenn ein neues Pflichtfeld in der OrderLine
hinzukommen sollte, müsste man alle Tests anpassen, selbst wenn das Feld für die Tests nicht relevant wäre. Das können wir vermeiden, indem wir eine Methode someOrderLine()
mit den relevanten Parametern Anzahl und Produkt extrahieren:
1class TestData {
2 static OrderLine someOrderLine(int amount, Product product) {
3 return OrderLine.builder()
4 .id(someInt())
5 .amount(amount)
6 .product(product)
7 .build();
8 }
9}
10
11class OrderUnitTest {
12 @Test
13 void shouldSumPriceWithDiscount() {
14 var order = Order.builder()
15 .id(someInt())
16 .date(someLocalDate())
17 .customer(someCustomerWithDiscount(5))
18 .line(someOrderLine(7, someProductAt(10)))
19 .line(someOrderLine(12, someProductAt(15)))
20 .build();
21
22 var sum = order.getSum();
23
24 then(sum).isEqualTo(237);
25 }
26}
Das Gleiche gilt für die Order
selbst. Also extrahieren wir eine Methode someOrder
, wobei wir nicht fest zwei Bestellpositionen übergeben, sondern ein varargs
:
1public class TestData {
2 static Order someOrder(Customer customer, OrderLine... lines) {
3 return Order.builder()
4 .id(someInt())
5 .date(someLocalDate())
6 .customer(customer)
7 .lines(List.of(lines))
8 .build();
9 }
10}
11
12class OrderUnitTest {
13 @Test
14 void shouldSumPriceWithDiscount() {
15 var order = someOrder(someCustomerWithDiscount(5),
16 someOrderLine(7, someProductAt(10)),
17 someOrderLine(12, someProductAt(15)));
18
19 var sum = order.getSum();
20
21 then(sum).isEqualTo(237);
22 }
23}
Wenn man das mit dem Code ganz am Anfang vergleicht, ist das schon ziemlich cool: Man erkennt sofort, worauf es ankommt. Und da ist nichts Unwesentliches mehr, das uns den Blick verstellen würde. Selbst wenn ein neues Pflichtfeld dazukommen würde, könnte man es einfach in TestData
einbauen (es sei denn, dass dieses Feld für die Tests relevant wäre, aber dann muss man den Test ohnehin anfassen).
Dass die Tests jetzt so leicht zu verstehen sind, hat man sich aber ehrlicherweise damit erkauft, dass man zumindest die Abstraktion der Test-Daten-Builder-Methoden verstehen und deren Implementierung vertrauen muss. Dafür braucht man ein gewisses Maß an Mut – und das Vertrauen, dass die Methoden auch genau das machen, was ihr Name ausdrückt. Manchen Menschen fällt das leichter als anderen. Ich meine immer wieder beobachten zu können, dass das mit wachsender Erfahrung leichter wird.
Builder-Klassen
Wenn ihr sehr genau aufgepasst habt, dann habt ihr es vielleicht schon bemerkt: Die Tests verwenden nicht mehr die Builder-Klassen von Lombok. Das klingt nach einer Kleinigkeit, ist aber wichtig, denn das bedeutet, dass wir die Datenhaltung aus den Tests ausgelagert haben. Wir könnten das Ganze auch einfach ohne Builder einsetzen. someOrderLine
könnte also auch so aussehen:
1public class TestData {
2 static OrderLine someOrderLine(int amount, Product product) {
3 var line = new OrderLine();
4 line.setId(someInt());
5 line.setAmount(amount);
6 line.setProduct(product);
7 return line;
8 }
9}
Echte Datenobjekte haben oft sehr viel mehr als nur diese drei Eigenschaften unserer OrderLine
. Und je nach Test sind andere Felder relevant. Dann für alle Tests die jeweils relevanten Parameter in eigenen someOrderLine
-Methoden mit verschiedenen Parametern abzubilden wird schnell wieder unübersichtlich und anstrengend. Wie könnte man denn das flexibler bauen? Mit einem Builder! Praktisch kann man das sogar so machen, dass wir der someOrder
-Methode keine fertigen Objekte Customer
und OrderLine
übergeben, sondern CustomerBuilder
bzw. OrderLineBuilder
, für die wir dann im someOrder
noch das fehlende build()
aufrufen:
1public class TestData {
2 static Order someOrder(CustomerBuilder customer, OrderLineBuilder... lines) {
3 return Order.builder()
4 .id(someInt())
5 .date(someLocalDate())
6 .customer(customer.build())
7 .lines(Stream.of(lines).map(OrderLineBuilder::build).toList())
8 .build();
9 }
10}
Die Methoden someCustomer
und someOrderLine
liefern also einen Builder. Wir entfernen außerdem die Parameter und setzen erstmal für alle Felder beliebige Standard-Werte mithilfe von someInt
, die dann von den Tests mit Werten überschieben werden können, die für den Test relevant sind:
1public class TestData {
2 static CustomerBuilder someCustomer() {
3 return Customer.builder()
4 .id(someInt())
5 .name("Customer-Name-" + someInt())
6 .discount(someInt());
7 }
8
9 static OrderLineBuilder someOrderLine() {
10 return OrderLine.builder()
11 .id(someInt())
12 .amount(someInt())
13 .product(someProductAt(someInt()));
14 }
15}
16
17class OrderUnitTest {
18 @Test
19 void shouldSumPriceWithDiscount() {
20 var order = someOrder(someCustomer().discount(5),
21 someOrderLine().amount(7).product(someProductAt(10)),
22 someOrderLine().amount(12).product(someProductAt(15)));
23
24 var sum = order.getSum();
25
26 then(sum).isEqualTo(237);
27 }
28}
Da gibt es jetzt natürlich viele Spielarten, die alle legitim sein können. Bspw. kann man die Lombok-@With
-Annotation verwenden. Dann hätte man keine Builder mehr und der Test sähe so aus:
1class OrderUnitTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 var order = someOrder(someCustomer().withDiscount(5),
5 someOrderLine().withAmount(7).withProduct(someProduct().withPrice(10)),
6 someOrderLine().withAmount(12).withProduct(someProduct().withPrice(15)));
7
8 var sum = order.getSum();
9
10 then(sum).isEqualTo(237);
11 }
12}
Zustand
Je größer unsere TestData
-Klasse wird, desto deutlicher wird, dass die darin gebauten Objekte eigentlich wenig miteinander zu tun haben; es liegt nahe, sie in TestCustomers
, TestProducts
, usw. aufzuteilen. Außerdem verlassen wir uns ja wieder auf die Lombok-Builder und haben dadurch tatsächlich zwei Builder-Ebenen: Die eigentlichen Builder, die einen Zustand halten, und die TestXXX
-Klassen, die nur statische Builder-Methoden enthalten. Diese Grenzen können wir aber auch verschieben! Beispielsweise können wir (wie oben) aufhören, den Lombok-Builder zu verwenden, und stattdessen den Zustand selbst in unserer eigenen Klasse halten, die Methoden sind dann nicht mehr static
. Um diesen Unterschied schon am Klassennamen sichtbar zu machen, benennen wir bspw. TestCustomers
(was bisher eine Sammlung von Konstanten und/oder statischen Methoden mit Bezug zu Testkunden ist) in TestCustomerBuilder
um: ein Builder für Testkunden.
1public class TestCustomerBuilder {
2 static TestCustomerBuilder someCustomer() {
3 return new TestCustomerBuilder();
4 }
5
6 private final Customer customer;
7
8 private TestCustomerBuilder() {
9 this.customer = new Customer();
10 customer.setId(someInt());
11 customer.setName("Customer-Name-" + someInt());
12 customer.setDiscount(someInt());
13 }
14
15 public TestCustomerBuilder withDiscount(int discount) {
16 this.customer.setDiscount(discount);
17 return this;
18 }
19
20 public Customer build() {
21 return customer;
22 }
23}
Die Tests können dabei so bleiben, wie sie waren. Die Methoden withDiscount
bzw. discount
sind nur nicht die mehr von Lombok, sondern unsere eigenen im TestCustomerBuilder
. Wir haben im Endeffekt nur die Datenhaltung von Lombok in unseren Builder verschoben.
Auch hier gibt es wieder verschiedene Spielarten. Bspw. könnten wir uns im TestCustomerBuilder
nur die relevanten Felder merken, und den Customer erst im build
bauen:
1public class TestCustomerBuilder {
2 static TestCustomerBuilder someCustomer() {
3 return new TestCustomerBuilder();
4 }
5
6 private int discount = someInt();
7
8 public TestCustomerBuilder withDiscount(int discount) {
9 this.discount = discount;
10 return this;
11 }
12
13 public Customer build() {
14 var customer = new Customer();
15 customer.setId(someInt());
16 customer.setName("Customer-Name-" + someInt());
17 customer.setDiscount(discount);
18 return customer;
19 }
20}
Wir haben so zusätzliche Möglichkeiten, die vor allem dann wichtig werden, wenn es nicht mehr nur um einzelne Felder geht. Beispielsweise könnten Lieferkosten abhängig von der Adresse berechnet werden. Wenn in den Tests aber immer einfach eine komplette Adresse steht, dann ist das ziemlich fragil: Es kann sich ja bspw. die Bewertung der Adresse ändern, weil ein Pizza-Lieferservice den Radius verändert oder umzieht. Im Test wäre es daher besser, einfach nur die fachlich relevante Aussage zu treffen: someCustomer().withNearbyAddress()
; welche Adressen dafür infrage kommen, ist Aufgabe des TestCustomerBuilder
. Ein Test-Daten-Builder kann also sehr viel mehr als immer nur einzelne Felder setzen, wie es der Lombok-Builder kann.
Entfernte Datenhaltung
So weit zu den Unit-Tests. Aber wie sieht es aus, wenn wir Tests schreiben (egal, ob wir die jetzt Integrations- oder System-Tests oder irgendwie anders nennen), die gegen echte Services laufen? Sie müssen Daten in verschiedenen Systemen hinterlegen, bevor wir den eigentlichen Testaufruf machen können. Bspw. müssen die Kunden im Kundenservice und die Produkte im Produktservice hinterlegt werden, bevor wir im Orderservice eine Bestellung anlegen und dann schließlich die Kosten berechnen lassen können.
Häufig wird dafür mit statischen Testdaten gearbeitet, bspw. ein Kunde mit der ID 123, der 5 Prozent Discount hat, und ein Kunde mit der ID 456 und 0 Prozent. Man kann leicht erkennen, dass das die gleichen beiden Probleme hat, die wir schon oben bei den Konstanten gesehen haben: Man kann einerseits im Test nicht sehen, welche Daten dieser Kunde hat, und andersrum am Kunden nicht, welche Daten überhaupt relevant sind. Andererseits kann es jederzeit passieren, dass jemand diese Testdaten aus Versehen verändert und damit die Tests kaputt macht. Oder es gibt eine Logik, dass ein Kunde mit mehr als 1 Mio. Jahresumsatz automatisch noch einen zusätzlichen Discount bekommt. Irgendwann schlagen dann plötzlich Tests fehl, die vorher noch liefen. Es ist also bei verteilten Fixtures sogar noch wichtiger, diese Abhängigkeiten auf ein Minimum zu reduzieren, und lieber für jeden Test neue Kunden und Produkte anzulegen. Selbst wenn die Produkte tatsächlich immutable sind, d.h. durch jede Änderung (auch des Preises) eine neue Produkt-ID erzeugt wird, sind die Tests lesbarer, wenn man dort die relevanten Daten direkt sieht.
Unser TestProductBuilder
ist der perfekte Ort für diese Setup-Logik. Unser build
benennen wir aber lieber in setup
um, weil nicht mehr nur lokal ein paar Felder zusammengeschraubt werden, sondern remote Daten aufgesetzt werden. Auch die Klassennamen TestXxxBuilder
passen nicht mehr so ganz, weil sie nicht mehr nur Testdaten bauen, sondern auch noch an andere Systeme übertragen und hinterlegen. Nennen wir sie also ProductFixture
, CustomerFixture
und OrderFixture
.
Die Services vergeben i.d.R. bei der Anlage auch neue IDs, mit denen wir die Daten in den verschiedenen Services verknüpfen. Die Klasse OrderFixture
kann dann so aussehen:
1public class OrderFixture {
2 static OrderFixture someOrder() {
3 return new OrderFixture();
4 }
5
6 public interface OrderApi {
7 @POST
8 Order place(Order order);
9 }
10
11 private final OrderApi orders = api(OrderApi.class);
12
13 private final OrderBuilder builder = Order.builder()
14 .date(someLocalDate());
15
16 public OrderFixture from(Customer customer) {
17 this.builder.customerId(customer.getId());
18 return this;
19 }
20
21 public OrderFixture with(int amount, Product product) {
22 this.builder.line(OrderLine.builder()
23 .amount(amount)
24 .productId(product.getId())
25 .build());
26 return this;
27 }
28
29 public Order setup() {
30 return orders.place(builder.build());
31 }
32}
33
34class OrderSystemTest {
35 public interface OrderApi {
36 @GET @Path("/{id}/sum")
37 int orderSum(@PathParam("id") int id);
38 }
39
40 OrderApi orders = api(OrderApi.class);
41
42
43 @Test
44 void shouldSumPriceWithDiscount() {
45 var customer = someCustomer().withDiscount(5).setup();
46 var productAt10 = someProduct().withPrice(10).setup();
47 var productAt15 = someProduct().withPrice(15).setup();
48 var order = someOrder().from(customer)
49 .with(7, productAt10)
50 .with(12, productAt15)
51 .setup();
52
53 var sum = orders.orderSum(order.getId());
54
55 then(sum).isEqualTo(237);
56 }
57}
Falls sich jemand fragt, was die Methode api()
macht: Sie generiert eine Implementierung zum API-Interface. Es gibt verschiedene Bibliotheken, die das können, bspw. Microprofile REST Client.
Fixture Interface
Mich persönlich stört dieser setup
-Methodenaufruf etwas beim Lesefluss. Ich bediene mich daher gerne eines kleinen Interfaces, das von allen unseren Fixture-Klassen implementiert wird, und eine statische given(Fixture<T>)
-Methode enthält, die einfach den setup()
-Aufruf macht. Dadurch ist die Aufteilung in given
, when
und then
noch deutlicher. Das ist zwar nur Syntactic Sugar und eigentlich nicht nötig, aber es bringt vielleicht auch etwas Klarheit.
1public interface Fixture<T> {
2 static <T> T given(Fixture<T> fixture) {
3 return fixture.setup();
4 }
5
6 T setup();
7}
8
9public class OrderFixture implements Fixture<Order> {
10 // ...
11
12 @Override
13 public Order setup() {
14 return orders.place(builder.build());
15 }
16}
17
18class OrderSystemTest {
19 @Test
20 void shouldSumPriceWithDiscount() {
21 var customer = given(someCustomer().withDiscount(5));
22 var productAt10 = given(someProduct().withPrice(10));
23 var productAt15 = given(someProduct().withPrice(15));
24 var order = given(someOrder().from(customer)
25 .with(7, productAt10)
26 .with(12, productAt15));
27
28 var sum = orders.orderSum(order.getId());
29
30 then(sum).isEqualTo(237);
31 }
32}
Microservices
Natürlich gibt es auch hier wieder viele Spielarten und die Real World™ hat auch noch so einige Gemeinheiten zu bieten. So kommt es bei Microservices häufig vor, dass ein Kunde redundant in verschiedenen Microservices hinterlegt sein muss, oft manche Felder hier, andere dort. Beispielsweise könnten die Kundenstammdaten in einem Service, aber der Rabatt-Prozentsatz in einem anderen Service gemanagt werden. Oder die Preise werden in einem separaten Preis-Service verwaltet. Das setup()
in der CustomerFixture
bzw. ProductFixture
würde dann beide Services aufrufen und selbst die Verknüpfung herstellen, ohne dass das im Test sichtbar wäre. Und das ist gut so, denn für den Test ist das gar nicht relevant.
In komplexeren Systemen gibt es für so etwas meistens irgendwelche Einrichtungsservices oder -prozesse, die diese Koordinierung vollständig übernehmen. Es bietet sich sehr an, die dann in einer Fixture aufzurufen, anstatt die Logik nachzubauen. Das ist nicht nur weniger Arbeit, es ist auch robuster gegen zukünftige Änderungen an der Einrichtung. Außerdem sorgt es für Testdaten, die über alle Systeme hinweg konsistent und realistisch sind.
Auch das kann man mit Fixtures sehr schön kapseln, und die Tests möglichst fachlich halten: Sie wollen von solchen Details gar nichts wissen. Selbst wenn an den Rohrleitungen etwas verändert werden muss, müssen das nur die Handwerker wissen; die Bewohner können sie weiterhin so benutzen, wie zuvor.
Und then
?
Wenn wir in unserem Test nicht nur die Bestellsumme berechnen, sondern eine komplette Rechnung generieren, dann wollen wir prüfen, ob darauf die richtige Kunden-ID angegeben ist. Dazu greifen wir auch im then
-Teil unseres Tests auf die erzeugte Kundennummer zu, die ja ggf. vom Kunden-Service erzeugt wurde:
1class OrderSystemTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 var customer = given(someCustomer().withDiscount(5));
5 // ...
6
7 var invoice = orders.createInvoice(order.getId());
8
9 then(invoice.getCustomerId()).isEqualTo(customer.getId());
10 then(invoice.getSum()).isEqualTo(237);
11 }
12}
Das kann man auch auf Daten wie das Bestelldatum ausweiten, das vielleicht nicht vom Service, sondern von unseren Fixtures generiert wird, also then(invoice.getOrderDate()).isEqualTo(order.getDate());
. Wenn wir dem Prinzip folgen wollen, dass alle relevanten Daten im Test explizit vorgegeben werden, dann ist es vielleicht so noch besser:
1class OrderSystemTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 // ...
5 var orderDate = someLocalDate();
6 var order = given(someOrder()
7 .at(orderDate)
8 .from(customer)
9 .with(7, productAt10)
10 .with(12, productAt15));
11
12 var invoice = orders.createInvoice(order.getId());
13
14 then(invoice.getOrderDate()).isEqualTo(orderDate);
15 // ...
16 }
17}
Spätestens wenn man sehr viele Daten vergleichen muss, wird das aber unübersichtlich. Es kann sein, dass wir dann besser auf dieses Prinzip verzichten und lieber einfach aus der Fixture auch die dort generierten Daten für den Vergleich auslesen. Bspw. die Adresse:
1class OrderSystemTest {
2 @Test
3 void shouldSumPriceWithDiscount() {
4 var customer = given(someCustomer().withDiscount(5));
5 // ...
6
7 var invoice = orders.createInvoice(order.getId());
8
9 then(invoice.getAddress()).isEqualTo(customer.getAddress());
10 // ...
11 }
12}
Es geht uns ja nur darum, dass die Daten korrekt vom Kundensystem in die Rechnung übertragen wurden. Welche Daten das genau sind, ist nicht relevant.
Wenn man das Konzept der Fixtures richtig verstanden hat, dann kann man die verschiedenen Spielarten gezielt einsetzen, um robuste und leicht verständliche Tests zu schreiben.
tl;dr
Je verteilter die Daten für Tests sind, desto wichtiger ist es, sie so aufzusetzen, dass man keine unangenehmen Überraschungen provoziert. Einerseits sollte man direkt im Test alle für ihn relevanten Daten sehen können (und sonst keine!). Andererseits sollte man lieber jedes Mal neue Daten anlegen, anstatt sich auf Daten zu verlassen, die irgendwie irgendwann von irgendjemandem verändert werden oder Nebenwirkungen haben könnten. Das gilt aber auch schon für Unit-Tests ab einer gewissen Komplexität.
Behalten wir also das Komplexitätsmonster im Zaum. Viele Werkzeuge dafür kennen wir ja schon. Wir müssen sie nur noch an unsere Tests anpassen und nutzen!
Weitere Beiträge
von Rüdiger zu Dohna
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Rüdiger zu Dohna
IT Consulting Expert
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.