Mittwoch, 30. Oktober 2013

Test Doubles by Example

Unit Tests sollen den produktiven Code gegen Fehler absichern. Es entsteht eine Art Sicherheitsnetz, das auch größere Umbauarbeiten am Code (Refactorings) fast gefahrlos erlaubt. Damit aber keine Pseudosicherheit aufgebaut wird, müssen Unit Tests einigen Grundregeln folgen. Diese wurden von Tim Ottinger und Jeff Langr sehr gut als FIRST-Kriterien zusammengefasst.

Test Doubles leisten einen unschätzbaren Wert zur Erreichung der ersten beiden Kriterien FAST und ISOLATED. Die Kritierien REPEATABLE, SELF-VERIFYING und TIMELY sind durch keine Tools erreichbar und müssen durch die Disziplin jedes einzelnen Entwicklers umgesetzt werden.


FAST bedeutet schnelle Ausführungszeit von Unit Tests. Damit können die Tests häufig ausgeführt werden und man bekommt häufig Feedback. Zusätzlich kann man. z.B. die organisatorische Regel aufstellen, dass vor einem Checkin von Code in die Versionsverwaltung erst die Unit Tests erfolgreich gelaufen sein müssen. Diese Regel wird schnell zur Qual, wenn die Tests mehrere Minuten dauern. Letztendlich führt das dazu, das weniger häufig eingecheckt wird. Und das ist auch nicht Sinn der Sache.

Ausführungszeiten einzelner Tests von über 1 Sekunde sind oftmals ein Indiz für das Vorliegen eines Integrationstests. Gegen Integrationstests ist an sich nichts einzuwenden, aber dann sollte der betreffende Test auch die klare Verantwortlichkeit des Testens der Integration von Bausteinen haben. Die Anzahl der Integrationstests in einem System sollte nur einen Bruchteil der Unit-Tests sein, um das schnelle Feedback nicht zu gefährden. Ggf. sollten Integrationstests in eine eigene Testsuite ausgelagert und separate ausgeführt werden.

Werden keine Gesamttestzeiten unter 2 Minuten erreicht und laufen die Einzeltests unter 100 ms, dann sollte das System in Einzelkomponenten aufgeteilt und jede Komponente separat getestet werden. Wichtig ist die Vermeidung sogenannte "dirty hybrids", also der Vermengung von Unit und Integrationstests.

Das genaut meint auch ISOLATED. Jeder Test soll genau eine Funktionalität prüfen. Dieses Prinzip ist im Produktivcode als Single-Responsibility-Prinzip bekannt. Aber wenn z.B. eine Datei im Filesystem gespeichert werden soll, dann sind die möglichen Fehlerurachen vielfaltig. Es kann nicht genügend Speicherplatz zur Verfügung stehen, die Rechte können nicht ausreichend sein oder ganz krass kann die Platte nicht erreichbar sein. Wie teste ich jetzt den Produktivcode gegen diese verschiedenen Fehlerquellen, insbesondere wenn diese in i.d.R. gar nicht auftreten?

Wichtiger noch als das genannte Beispiel ist die Entkopplung von Abhängigkeiten innerhalb des Codes. Bei einer sauber in Schichten aufgeteilten Architektur kann die jeweils "untere" Schicht durch Test Doubles ersetzt werden. Somit wird beim Test der Businesslogik nicht die gesamte Persistence mitgetestet. Das bringt Geschwindigkeit und testet nur die Logik.

Zur Reduktion von langen Ausführungszeiten und um Abhängigkeiten abzukoppeln, können Test Doubles eingesetzt werden. Der Begriff wurde von den Stunt Doubles bei Schauspielern entnommen und charakterisiert deren Aufgabe sehr gut. Dabei stehen zwei Aufgaben im Vordergrund:
  1. Ersetzen von nicht relevantem Produktivcode gegen einfacher zu handhabenden Testcode und
  2. Schaffung spezieller Testvoraussetzungen.

Folgende Arten von Test-Doubles gibt es:

Diese Begriffe werden oft verwechselt und nicht einheitlich verwendet. Die o.g. Klassifikation ist den "xUnit Test Patterns" von Gerard Meszaros entnommen. An Hand eines einheitlichen Beispiels sollen alle Test Doubles erläutert werden - mit einer Ausnahme: Fakes.

Fakes sind alternative Komponenten, die das Original ersetzen wenn es nicht zur Verfügung steht oder dessen Setup zu aufwändig ist. Das beste Beispiel hierfür ist eine Datenbank. An Stelle einer aufwändigen Oracle-Installation wird eine Memory-Datenbank wie H2 oder Derby verwendet. Damit lassen sich auch leicht vordefinierte Datasets auf dem Entwicklerrechner für die Tests laden.

Zur Demonstration der Test-Doubles wird eine Rechnung erstellt, zu der mehrere Rechnungspositionen hinzugefügt werden können. Die Rechnungssumme ergibt sich aus der Anzahl und dem Einzelpreis der einzelnen Positionen. Um das Beipiel ein klein wenig zu verkomplizieren soll jede Rechnungsposition ihre Rechnung kennen. Es entsteht eine bidirektionale Assoziation.


public class LineItem {
 private Invoice invoice;
 private final String product;
 private final double price;
 private final int amount;

 public LineItem(String product, double price, int amount) {
  this.product = product;
  this.price = price;
  this.amount = amount;
 }

 public Invoice getInvoice() {
  return invoice;
 }

 public void setInvoice(Invoice invoice) {
  this.invoice = invoice;
 }

 public String getProduct() {
  return product;
 }

 public double getPrice() {
  return price;
 }

 public int getAmount() {
  return amount;
 }
}

Zusätzlich gibt es einen Kunden, ...

public class Customer {
 private final String name;
 private final Address address;
 
 public Customer(String name, Address address) {
  this.name = name;
  this.address = address;
 }

 public String getName() {
  return name;
 }

 public Address getAddress() {
  return address;
 }
}

... der eine Adresse haben muss.

public class Address {
 private final String country;
 private final String city;
 private final String street;

 public Address(String country, String city, String street) {
  this.country = country;
  this.city = city;
  this.street = street;
 }

 public String getCountry() {
  return country;
 }

 public String getCity() {
  return city;
 }

 public String getStreet() {
  return street;
 }
}

Der nachfolgende Test ist bewusst sehr einfach gehalten. Es soll ausschließlich getestet werden, ob die Gesamtsumme korrekt berechnet wird. Für Unit-Tests gibt es immer drei Phasen. Für die Namen dieser Phasen gibt zwei Alternativen. Die xUnit-Patterns verwenden Setup, Excercise und Verify. Die vierte Phase "Teardown" kann im Java-Bereich dank autmatischer Garbage Collection meistens entfallen.

Das von Dan North etablierte Behaviour Driven Development arbeitet dagegen mit den Begriffen Given, When und Then für den intitalen Kontext, das Ereignis und das erwartete Ergebnis. Diese Begriffe sind m.E.praktikabler, was übrigens auch Martin Fowler findet.

Die Phasen sollten auch als Testname verwendet werden, damit der Sinn des Tests klar wird. Gegenüber "testGetTotal()" ist "newInvoice_addTwoItems_totalCorrect()" viel aussagekräftiger. Es gäbe schließlich noch andere denkbare Möglichkeiten, die Berechnung der Rechnungssumme zu testen.

Die Anforderung im obigen Beispiel, dass eine Rechnung ohne eine Rechnungsposition keinen Sinn macht, erschwert die saubere Trennung der Phasen. Eine Rechnung muss immer mit der ersten Buchungsposition erstellt werden. Damit wird in der Phase der Testvorbereitung bereits das erste Ereignis ausgeführt.

import static org.junit.Assert.assertEquals;
import java.util.Date;
import org.junit.Test;

public class InvoiceTest {

 @Test
 public void newInvoice_addTwoItems_totalCorrect() {
  // Given (Setup)
  Address address = new Address("country", "city", "street");
  Customer customer = new Customer("customer", address);
  LineItem beer = new LineItem("beer", 1.5, 2);
  Invoice invoice = new Invoice(customer, new Date(), beer);

  // When (Excercise)
  LineItem crisps = new LineItem("crisps", 1.0, 1);
  invoice.addItem(crisps);
  
  // Then (Verify)
  assertEquals(4.0, invoice.getTotal(), 0.001);
 }
}

Nachdem jetzt die Motivation für Test Doubles geklärt und ein Beispiel für einen Unit-Test erstellt wurde, wird im nächsten Artikel der Einsatz von Dummy-Objekten an diesem Beispiel demonstriert.

Keine Kommentare:

Kommentar veröffentlichen