Blog: Java, Software-Entwicklung & mehr

Wenn Tests zum Problem werden

27.07.2015 Thomas Mohme

Wer hat nicht schon einmal vor einem fehlgeschlagenen Test gesessen und sich gefragt, was eigentlich getestet wird und was das mit dem Fehler zu tun hat? Wiederholtes und langes analysieren in solchen Situationen kostet unnötig Zeit und Nerven. Beides kann besser in die Erstellung besserer Tests investiert werden.

Ich führe hier nur einen kleinen Ausschnitt der üblichen Probleme, ihrer Ursachen und möglicher Verbesserungen auf. Einen guten Teil dieses Wissens verdanke ich Gerard Meszaros und seinem Buch xUnit Patterns, in dem sehr viel mehr Aspekte des Themas sehr viel ausführlicher behandelt werden. Wem die 944 Seiten des Buchs zu viel Stoff sind, der sollte zumindest einen Blick auf die zugehörige Webseite werfen. Insbesondere die Abschnitte Goals of Test Automation und Test Smells sind einen Blick wert. Die Links in diesem Artikel verweisen auf Webseiten zu dem Buch.

Bei automatisierten Tests müssen wir uns ebenso wie beim produktiven Code fragen, was wir erreichen wollen. Meist ist das Hauptziel von automatisierten Tests, mittel- bis langfristig Kosten zu senken, weil aufwändige manuelle Tests entfallen können und Fehler (die direkt und/oder indirekt Kosten verursachen) vermieden werden. Tests sind also schlichtweg eine Investition in die Zukunft, die sich amortisieren soll.

Hierzu müssen die Tests sowohl effektiv, als auch effizient sein. Effektiv sind sie, wenn sie Fehlverhalten aufdecken. Das ist relativ einfach überprüfbar und funktioniert meist. Effizient sind Tests, wenn sie für die Erfüllung ihrer Aufgabe nur eine angemessene Menge von Ressourcen (sowohl für ihre Ausführung, als auch für ihre Pflege) beanspruchen. Genau hier verbergen sich häufig Probleme, weil es eben kein hartes, leicht überprüfbares Kriterium dafür gibt, ob ein Test effizient ist.

Oben wurde angesprochen, dass Tests betriebswirtschaftlich gesehen Investitionen sind, die sich amortisieren sollen. Aber wie sieht die Rechnung aus, wenn die automatisierten Tests nicht nur bei ihrer Entwicklung, sondern dauerhaft signifikante zusätzliche Kosten verursachen? Dann verlieren die automatisierten Tests plötzlich eine Teil ihrer Existenzberechtigung. In der Praxis sieht man immer wieder Tests, die wegen unterschiedlicher Probleme einen zweifelhaften Nutzen haben.

Symptome

Am häufigsten begegnen mir die folgenden Symptome:

  • Weder an dem zu testenden Code ("SUT", System Under Test), noch an dem Test wurde etwas geändert. Trotzdem zeigt der Test einen Fehler an. Ein Fall von Fragile Tests.

  • Die Aussage des Test ist zu ungenau. Am Ende eines komplexen Prozesses stimmt das Ergebnis nicht mit dem erwarteten Wert überein. Jetzt beginnt eine zeitraubende Ursachenanalyse. Der Test liefert keine Defect Localization.

  • Im testSuccessfulProcessing2 schlägt irgendeine Assertion für ein Attribut eines Objektes aus einer Liste zu. Wie entscheide ich, ob das ein Problem des SUT oder des Tests ist? Was ist - auf einer fachlichen Ebene - das korrekte Verhalten? Das Ziel Test as Documentation wird nicht erreicht.

  • Während der Ausführung einer Testmethode gibt es eine Exception. Ist dies ein Problem des SUT oder des Tests? Muss ich erst den Test analysieren um das herauszufinden? Der Test hat keinen klaren Aufbau, der eine Unterscheidung offensichtlich macht.

  • Im SUT wird eine Änderung durchgeführt. Eine Menge von Tests signalisiert Fehler und muss angepasst werden. Hier haben wir zu viel Test Overlap.

Mit Sicherheit sind jedem von uns diese Probleme schon einmal begegnet. Alle Varianten münden letztlich darin, dass sehr viel mehr Zeit in die Pflege der Tests investiert werden muss, als eigentlich nötig wäre.

Ursachen

Nach meiner Erfahrung wiederholden sich nicht nur die Probleme, sondern auch die Ursachen immer wieder:

Fragile Tests werden häufig durch zu weit gefasste Tests erzeugt: Der Test ist von zu vielen Dingen abhängig. Oft sind hiervon Integrationstests betroffen, die von einer Menge von Daten in einer Datenbank abhängig sind. Hierbei passiert es schnell, dass man den Überblick darüber verliert, welche Daten alle "stimmen" müssen, damit der Test durchläuft.

Mangelnde Defect Localization hat meist eine ähnliche Ursache. Nur ist hier der Test nicht von zu vielen Daten, sondern von zu vielen Code-Zweigen abhängig. Bei Integrationstests oder Abnahmetests ist dies normal und unvermeidlich - sie haben andere Ziele. Bei einem Unit-Test ist der getestete Funktionsumfang zu groß, wenn anschließend aufwändige Eingrenzungen erforderlich sind.

Damit Test as Documentation funktioniert muss ein Test rigoros an fachlichen Anforderungen orientiert werden. Dies fängt beim Namen an und setzt sich in seinem Aufbau fort. Sehr häufig findet man Tests, aus denen man klar ablesen kann, was - auf einer niedrigen technischen Ebene - getestet wird. Über die fachliche Bedeutung des Tests wird der Leser jedoch im unklaren gelassen. Ein Beispiel dafür ist der oben genannte Testname testSuccessfulProcessing2, sowie Prüfungen à la assertEquals(3, a).

Tests mit unklarem Aufbau vermengen die einzelnen Phasen eines Tests. Statt Setup, Execution, Verification und ggf. Teardown klar voneinander getrennt (auch optisch!) zu behandeln findet man zum Beispiel Setup, gefolgt von ein wenig Verification, gefolgt von weiterem Setup, gefolgt von Execution, gefolgt von Verification, gefolgt von weiterer Execution . . . Oft ist dies auch ein Indiz für einen unklar fokussierten Test.

Test Overlap entsteht auf verschiedene Arten: Tests auf der gleichen Ebene sind zu unscharf, oder die gleiche Funktionalität wird durch Tests auf mehreren Ebenen getestet, beispielsweise sowohl durch Unit-Tests, als auch durch Integrationstests. In einer Layer-Architektur kann dies dann auch noch auf mehreren Layern passieren. Eine weitere Variante ist das Kopieren von Setup-Code, statt ihn auszulagern.

Wie geht das besser?

Nach all den negativen Hinweisen auf die Auswirkungen ist die gute Nachricht, dass es nicht aufwändig sein muss, diese Probleme zu umschiffen. Meist führt schon eine etwas gezieltere Herangehensweise, kombiniert mit etwas Clean Code zu deutlich effizienteren Ergebnissen.

Fangen wir mit der Aufteilung der Tests an.

Jede Software die wir entwickeln kostet Geld. Dieser Geldeinsatz wird dadurch gerechtfertigt, dass die Software anschließend Aufgaben erfüllt, die uns wichtig sind. Sie stellt Features bereit. Also ist es nur natürlich, wenn wir die Tests an diesen Features orientieren.
Jetzt heißt es "Ja, aber das sind Abnahmetests, die mir Null Defect Localization liefern."
Nein. Das Prinzip, Tests an den Features eines Stücks Software zu orientieren, ist auf allen Ebenen anwendbar.
Schon allein eine konsequente Orientierung an Features führt zu einer Aufteilung der Test, die Überschneidungen vermeidet. Außerdem fungieren die Tests gleichzeitig als Dokumentation.

Ein Methodenname wie testVoucherValidatorReturnsTrue sagt vielleicht dem Autor der betreffenden Methode etwas. Alle anderen dürfen jetzt zum Sourcecode navigieren und versuchen, den technischen Ablauf zu verstehen und daraus die ursprünglichen fachlichen Anforderungen zu rekonstruieren - was an sich schon ein fragwürdiges Unterfangen ist. Dagegen liefert voucherValidator_accepts_unused_voucher dem Leser unmittelbar eine klare Vorstellung von der fachlich zu erfüllenden Aufgabe, dem Feature, das die Software bietet. Dadurch ermöglichen wir es dem Leser des Tests, im Falle eines Fehlers zu beurteilen, ob das SUT oder der Test die Ursache ist. Wie kommt man zu solch einem Test-Zuschnitt? Ganz einfach: Wenn jemand dieses Stück Software benutzen können soll und keinen Zugriff auf den Sourcecode hat, dann muss jeder von außen beobachtbare Aspekt des Verhaltens in der Schnittstelle beschrieben sein. Für jeden dieser Aspekte sollte es genau einen Test geben. Für das obige Beispiel bedeutet das auch einen voucherValidator_rejects_used_voucher Test.

Wenn wir schon so weit sind, ist der Schritt hin zu Test-Driven Development auch nicht mehr groß. Ein sehr motivierender Einstieg hierfür ist das Buch "Growing Object-Oriented Software, Guided by Tests" von Steve Freeman und Nat Pryce.

Die klaren Namen unterstützen uns dabei, die Tests auf genau einen Aspekt fokussiert zu halten. Mit dieser einfachen Maßnahme haben wir für die Defect Localization, Test as Documentation und die Vermeidung von Test Overlap schon eine Menge erreicht. Das beste dabei: Gekostet hat es praktisch nichts!

Weiter geht es mit dem Inhalt des Tests.

Gerard Meszaros beschreibt in seinem Buch, dass nahezu alle Tests aus der immer gleichen Abfolge von drei oder vier Phasen bestehen. Es beginnt mit einer setup Phase, in der benötigte Voraussetzungen geschaffen werden. Anschließend wird in einer execution Phase das SUT veranlasst, das zu testende Verhalten zu zeigen. Schließlich wird in der verification Phase das beobachtete Verhalten überprüft. Vereinzelt schließt sich noch eine teardown Phase an, in der Spuren des Tests beseitigt werden müssen.

Es gibt einen einfachen Trick, um die mentale Fokussierung auf genau den einen zu testenden Aspekt (den der Name des Tests bezeichnet) aufrecht zu erhalten: Der Test wird von hinten nach vorne aufgebaut!

Der Name des Tests drückt bereits aus, welches beobachtbare Verhalten getestet werden soll. Dieses fachliche Wissen wird nun auf eine technische Ebene übersetzt: An welchen Stellen kann man nach Abschluss des Tests welche Inhalte erwarten? Und vor allem: Welcher dieser Effekte tritt nur bei dem zu testenden Verhalten auf? Hiermit lassen sich die verifications formulieren. Effekte, die bei mehreren Verhaltensvarianten in gleicher Weise auftreten, sollten ihren eigenen Test bekommen. Wenn es dabei schwer fällt, diesen Test passend zu benennen, ist das häufig ein Indiz für eine unklare funktionale Zerlegung des SUT. Es sollte vermieden werden, einfach "alle möglichen" beobachtbaren Werte zu verifizieren. Dies führt regelmäßig dazu, dass nach einer Änderung des SUT viele Tests angepasst werden müssen.

Anschließend kann der Code der execution Phase geschrieben werden, der das SUT dazu bringen soll, das gewünschte Verhalten zu zeigen. Dies sind selten mehr als ein, zwei Zeilen.

Zum Schluss werden in der setup Phase die Voraussetzungen geschaffen, damit das SUT in der execution Phase das gewünschte Verhalten zeigt. Hierbei ist es wichtig, nur das absolut erforderliche Minimum aufzubauen. Alles, was darüber hinaus geht, birgt das Potenzial, irgendwann angepasst werden zu müssen, weil sich Interna des SUT geändert haben. Besonderes Augenmerk sollte hier auf Mocks gerichtet werden. Für jeden einzelnen Aufruf muss hinterfragt werden, ob es ein wesentlicher und damit für die Erfüllung der zu prüfenden Funktionalität erforderlicher Seiteneffekt ist, oder ob der Aufruf bei diesem Test einfach notwendig ist, damit anschließend das gewünschte Verhalten erzielt werden kann. Im ersten Fall sollte die Mock-Spezifikation möglichst strikt ausgelegt sein. Im zweiten Fall sollte sie umgekehrt eher möglichst locker ausgelegt sein.

In der Praxis gehört extrem viel Disziplin dazu, diese Reihenfolge vollständig einzuhalten. Auf die Vollständigkeit der Einhaltung kommt es aber gar nicht an. Die gewünschte mentale Fokussierung erreicht man auch, wenn z.B. mit den Details der verification zusammen einen "Entwurf" der execution schreibt, um in der verification Phase Code-Completion nutzen zu können.

Durch dieses Vorgehen schaffen wir einen klaren Aufbau, der es den Lesern ermöglicht, den Test mit einem Minimum an Zeitaufwand zu verstehen. Weil sowohl in der verification nur ein Minimum an Effekten geprüft wird, als auch im setup nur das absolute Minimum an Werten bereitgestellt wird, sind derartig aufgebaute Tests weitgehend resistent gegen Veränderungen der internen Abläufe des SUT und damit wartungsarm.

Insgesamt führt dieses Vorgehen zu mehr kleineren Tests.

Sind wir jetzt schon fertig?
Nein, denn jetzt haben wir zwar einen funktional guten Test geschrieben, dessen expressiveness aber noch zu wünschen übrig lässt.

Expressiveness oder Ausdruckskraft ist Fähigkeit des Codes, seine Intention zu kommunizieren. Das Warum, die fachliche Bedeutung.

Gerade diese fachliche Bedeutung geht in einem Gestrüpp von rein technisch motiviertem Code leicht unter. Wer sieht auf den ersten Blick, dass im setup mit dem nicht schwierig zu verstehenden, aber rein technischem Code

Collection<Account> accountCollection = new ArrayList<Account>();
Account account1 = new Account();
account1.setAccountStatus(7);
int year1 = Calendar.getInstance().get(Calendar.YEAR);
Calendar cal1 = Calendar.getInstance().set(year1, 0, 1, 0, 0, 0);
cal1.set(MILLISECOND, 0);
account1.setValidFrom(cal1);
Calendar cal2 = Calendar.getInstance().set(year1, 11, 31, 23, 59, 59);
cal2.set(MILLISECOND, 999);
account1.setValidTo(cal2);
Account account1 = new Account();
account1.setAccountStatus(7);
int year1 = Calendar.getInstance().get(Calendar.YEAR);
Calendar cal1 = Calendar.getInstance().set(year1, 0, 1, 0, 0, 0);
cal1.set(MILLISECOND, 0);
account1.setValidFrom(cal1);
Calendar cal2 = Calendar.getInstance().set(year1, 11, 31, 23, 59, 59);
account1.setValidTo(cal2);
accountCollection.add(account1);
accountCollection.add(account2);

die fachliche (Domain) Aussage "mehrere in diesem Kalenderjahr gültige Accounts" gemeint ist. Ist die folgende Variante nicht viel besser verständlich?

Collection<Account> = someAccountsValidThisCalendarYear();

Der erste - häufig zu findende - Ansatz ist grundsätzlich richtig. Trotzdem führt er schnell dazu, dass man den Wald vor lauter Bäumen nicht sieht. Statt uns mit der Lösung des fachlichen Problems zu beschäftigen werden wir gezwungen, ständig zwischen der niederen technischen Ebene und der abstrakteren fachlichen Ebene zu übersetzen. Das kostet nicht nur Zeit, es ist auch langweilig, ermüdend und dadurch fehleranfällig. Oder haben Sie etwa bemerkt, dass beim zweiten Account die Millisekunden nicht korrekt gesetzt wurden?

Wesentlich effizienter ist es, solchen rein technisch motivierten Code - wie im zweiten Beispiel - in eine kleine, nach der fachlichen Funktionalität benannten, Hilfsmethode zu verpacken. Das kostet wenig und bringt viel. Mit aktuellen IDEs ist die Extraktion solcher Code-Blöcke eine Sache von Sekunden. Die Ausdruckskraft des Codes wird enorm verbessert, wenn diese Technik angewendet wird.

Häufig braucht man in mehreren Tests ein ähnliches setup. Hier tritt nun ein weiterer positiver Effekt ein: Die kleinen Hilfsmethoden lassen sich oft wiederverwenden. Und wenn man nicht exakt das gleiche Ergebnis braucht, erweitert man sie vielleicht um einen Parameter. Oder man verändert das zurückgelieferte Ergebnis in einer weiteren Zeile. In jedem Fall unterstützt dies Wiederverwendung und macht simples Code-Duplizieren weniger attraktiv.

Bleibt noch die Frage, was man alles in solche Hilfsmethoden verpacken sollte, und was nicht. Schließlich sollen die Tests hierdurch übersichtlicher werden und nicht die Verständlichkeit leiden, weil man permanent zwischen verschiedenen Methoden hin- und herspringen muss.

Hierzu kann man als Daumenregel sagen, dass alles aus dem setup aus dem eigentlichen Test verschwinden sollte, was nicht eine der folgenden Bedingungen erfüllt:

  • Es wird in verification geprüft
  • Es handelt sich um Domain-Begriffe

Für alle Prüfungen in der verification ist es wichtig zu verstehen, warum genau dieser Wert erwartet wird. Dieser Zusammenhang wird verschleiert, wenn im setup der zu erwartende Wert vorher in irgendeiner Hilfsmethode gesetzt wird. In solchen Fällen sollte der Wert zumindest als Parameter an die Hilfsmethode übergeben werden. Alle Werte, die zwar aus technischen Gründen gebraucht werden, aber sonst keinen direkten Einfluss auf die durchgeführten Prüfungen haben, sollten aus dem Blickfeld verschwinden.

In gleicher Weise gilt dies für die verifications: Bei allen technisch aufwendigen Prüfungen, die möglicherweise auch noch mehrfach in ähnlicher Form vorkommen, sollte man hinterfragen, ob sie nicht durch passende Hilfsmethoden, die auf Domain-Ebene benannt sind, ersetzt werden können.

Sehr eindrucksvoll demonstriert Gerard Meszaros dies in seiner Präsentation "Maximizing Expressiveness of Automated Tests".
Auf gänzlich andere Art vertritt Kevlin Henney in seiner Präsentation "Programming with GUTs" sehr ähnliche Standpunkte.

Speziell diese letzten Schritte zur Steigerung der Expressiveness ist nichts Test-spezifisches. Im wesentlichen sind sie eine Adaption der gängigen Clean Code Regeln für Softwaregestaltung.

Test Refactoring

Nehmen wir einen Vertreter der Gattung der "teuren" Tests und bauen ihn in kleinen Schritten um.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Address shippingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Customer customer = new Customer(99, "John", "Doe", billingAddress, shippingAddress);
    Product product = new Product(88, "Shiny Opal", new BigDecimal("19.95"));
    Order order = new Order(customer);
    // Exercise SUT
    order.addItemQuantity(product, quantity);
    // Verify Outcome
    List<OrderItem> orderItems = order.getItems();
    if (orderItems.size() == 1) {
      OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
      assertEquals(order, actualOrderItem.getInvoice());
      assertEquals(product, actualOrderItem.getProduct());
      assertEquals(quantity, actualOrderItem.getQuantity());
      assertEquals(new BigDecimal("19.95"), actualOrderItem.getUnitPrice());
    } else {
      assertTrue("Order should have exactly one line item", false);
    }
  }

Zunächst wirkt der Test nicht sonderlich kompliziert.
Die bisher versteckte Kohäsion der einzelnen Blöcke können wir durch einfügen weniger Leerzeilen sichtbar machen.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Address shippingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Customer customer = new Customer(99, "John", "Doe", billingAddress, shippingAddress);
    Product product = new Product(88, "Shiny Opal", new BigDecimal("19.95"));
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    if (orderItems.size() == 1) {
      OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
      assertEquals(order, actualOrderItem.getInvoice());
      assertEquals(product, actualOrderItem.getProduct());
      assertEquals(quantity, actualOrderItem.getQuantity());
      assertEquals(new BigDecimal("19.95"), actualOrderItem.getUnitPrice());
    } else {
      assertTrue("Order should have exactly one line item", false);
    }
  }

Die Bedingungslogik im Test wird durch eine passende Guard-Assertion ersetzt.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Address shippingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Customer customer = new Customer(99, "John", "Doe", billingAddress, shippingAddress);
    Product product = new Product(88, "Shiny Opal", new BigDecimal("19.95"));
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertEquals(order, actualOrderItem.getInvoice());
    assertEquals(product, actualOrderItem.getProduct());
    assertEquals(quantity, actualOrderItem.getQuantity());
    assertEquals(new BigDecimal("19.95"), actualOrderItem.getUnitPrice());
  }

Die Vergleichsdaten der Assertions werden so angegeben, dass eine Rückverfolgung zum Setup einfach und sicher möglich ist.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Address shippingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Customer customer = new Customer(99, "John", "Doe", billingAddress, shippingAddress);
    Product product = new Product(88, "Shiny Opal", new BigDecimal("19.95"));
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertEquals(expectedOrderItem.getInvoice(), actualOrderItem.getInvoice());
    assertEquals(expectedOrderItem.getProduct(), actualOrderItem.getProduct());
    assertEquals(expectedOrderItem.getQuantity(), actualOrderItem.getQuantity());
    assertEquals(expectedOrderItem.getUnitPrice(), actualOrderItem.getUnitPrice());
  }

Die logisch zusammengehörigen Assertions werden in eine eigene Methode ausgelagert.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Address shippingAddress = new Address("Underground Motel", "7 Oliver Street", "Coober Pedy", "SA 5723", "Australia");
    Customer customer = new Customer(99, "John", "Doe", billingAddress, shippingAddress);
    Product product = new Product(88, "Shiny Opal", new BigDecimal("19.95"));
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertOrderItemEquals(expectedOrderItem, actualOrderItem);
  }

Die als Literal angegebenen Setup-Daten werden ersetzt, sofern sie nicht geprüft werden. Sie lenken nur von den wirklich wichtigen Dingen ab und provozieren Fragen ("Gibt es besondere Regeln für australische Adressen?").

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = new Address(getUniqueString(), getUniqueString(),
            getUniqueString(), getUniqueString(), getUniqueString());
    Address shippingAddress = new Address(getUniqueString(), getUniqueString(),
            getUniqueString(), getUniqueString(), getUniqueString());
    Customer customer = new Customer(getUniqueInt(), getUniqueString(), getUniqueString(),
            billingAddress, shippingAddress);
    Product product = new Product(getUniqueInt(), getUniqueString(), new BigDecimal("19.95"));
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertOrderItemsEquals(expectedOrderItem, actualOrderItem);
  }

Ungeprüfte Informationen werden durch Hilfsmethoden ersetzt.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Address billingAddress = createAnonymousAddress();
    Address shippingAddress = createAnonymousAddress();
    Customer customer = createAnonymousCustomer(billingAddress, shippingAddress);
    Product product = createAnonymousProduct();
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertOrderItemsEquals(expectedOrderItem, actualOrderItem);
  }

...und nochmal

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Customer customer = createAnonymousCustomer();
    Product product = createAnonymousProduct();
    Order order = new Order(customer);

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertOrderItemsEquals(expectedOrderItem, actualOrderItem);
  }

...solange, bis im Setup nur noch die minimal erforderlichen Daten erzeugt werden.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Product product = createAnonymousProduct();
    Order order = createAnonymousInvoice();

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    List orderItems = order.getItems();
    assertEquals("number of items", orderItems.size(), 1);

    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    OrderItem expectedOrderItem = newOrderItem(order, product, quantity, total);
    OrderItem actualOrderItem = (OrderItem) orderItems.get(0);
    assertOrderItemsEquals(expectedOrderItem, actualOrderItem);

  }

Technischer Low-Level Code wird durch Domain-Formulierungen ersetzt.

  @Test
  public void testAddOrderItem() {
    // Setup Fixture
    final int quantity = 5;
    Product product = createAnonymousProduct();
    Order order = createAnonymousInvoice();

    // Exercise SUT
    order.addItemQuantity(product, quantity);

    // Verify Outcome
    BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
    hasExactlyOneLineItem(order, 
      expectedLineItem(order, product, quantity, total));

  }

Der Test bekommt einen passenden Namen.
Durch Verwendung statischer innerer Klassen werden Tests gruppiert. Daraus ergibt sich der zusätzliche positive Effekt, dass die eigentlichen Testnamen nun etwas kürzer und damit lesbarer sein können.

  public static class AddItem {
    @Test
    public void quantityGreaterOne_itemValueIsProductPriceTimesQuantity() {
      final int quantity = 5;
      Product product = givenAnyProduct();
      Order order = givenAnEmptyInvoice();

      // when
      order.addItemQuantity(product, quantity);

      // then
      BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
      hasExactlyOneLineItem(order,
        expectedLineItem(order, product, quantity, total));
    }
  }

Das Schreiben des nächsten Tests ist nun schnell erledigt:


  public static class AddItem {
    @Test
    public void duplicateProduct_singleItemHasSumOfQuantities() {
      final int firstQuantity = 1;
      final int secondQuantity = 2;
      Product product = givenAnyProduct();
      Order order = givenAnEmptyInvoice();

      // when
      order.addItemQuantity(product, firstQuantity);
      order.addItemQuantity(product, secondQuantity);

      // then
      final int sumOfQuantities = firstQuantity + secondQuantity;
      BigDecimal total = product.getPrice().multiply(new BigDecimal(sumOfQuantities));
      hasExactlyOneLineItem(order,
        expectedLineItem(order, product, sumOfQuantities, total));
    }
  }

Am Ende dieses Prozesses verfügt man über Tests, die

  • robust sind, weil sie nur eine minimale Menge von Abhängigkeiten haben
  • klar auf Defects hinweisen, weil sie wenige Überschneidungen haben
  • als Dokumentation der Domain-Anforderungen dienen können, weil ihr Ablauf mit Domain-Begriffen formuliert ist
  • schnell und sicher verständlich sind
  • seltener angepasst werden müssen, weil sich eine Änderung im SUT nur auf wenige Tests auswirkt.

Natürlich sind die hier behandelten Probleme nicht die einzigen, die im Zusammenhang mit Tests auftreten können. Gerard Meszaros Buch umfasst nicht ohne Grund 944 Seiten. Aber nach meiner Erfahrung sind es Dinge, die häufig auftreten und einfach vermieden werden können.

Hilfsmittel

Um sich ein simples Gerüst für Tests einschließlich der Kommentare für die verschiedenen Phasen erzeugen zu lassen bieten sich die Template-Mechanismen moderner IDEs an.

Die eigentlichen Prüfungen - egal, ob ausgelagert oder inline - lassen sich mit Hilfe geeigneter Libraries wie Hamcrest oder AssertJ meist deutlich kompakter und gleichzeitig klarer formulieren, als mit den üblichen JUnit Assertions. Wenn in der Domain bestimmte Prüfungen immer wieder vorkommen, kann es sich zudem lohnen, dafür eigene Matcher zu schreiben. Dies erfordert nur wenig Aufwand und ermöglicht die unmittelbare Verwendung von Domain-Begriffen auch in den Prüfungen. Eigene Matcher können kleine (Lesbarkeits-) Wunder bewirken und gleichzeitig Code-Duplizierung eindämmen.

Einen Schritt weiter geht die Nutzung eines fortgeschritteneren Frameworks wie Spock zu dem Autor Peter Niederwieser eine Präsentation auf der SpringOne 2014 gehalten hat.

Eine weitere interessante Alternative für die Java Welt ist ScalaTest.

Beide fördern das Schreiben guter Tests im Sinne dieses Artikels, erfordern aber eine gewisse Bereitschaft, sich auf neues, möglicherweise unsicheres Terrain zu begeben.

Fazit

Um all die Verbesserungen zu erreichen, müssen wir nicht mehr Aufwand betreiben, als bei den problematischen Tests. Wir müssen lediglich mit einem anderen Mindset an die Erstellung herangehen.

Ähnlich wie beim Thema Clean Code erscheint das meiste als eine Selbstverständlichkeit, wenn man es liest. Zur Selbstverständlichkeit kann es aber nur werden, wenn man es verinnerlicht hat. Dafür hilft es - zumindest mir - die Kernargumente nachlesen zu können.