Unit Tests und testbarer Code

So vermeiden Sie typische Fehler beim Schreiben von Unit Tests

Thomas Papendieck

(c) Shutterstock / Bakhtiar Zein

Unit Tests sind in aller Munde. Es gibt wohl keinen Entwickler, der nicht davon gehört hat, dass Unit Tests ein Sicherheitsnetz bilden, das den Programmierer vor ungewollten Seiteneffekten seiner Änderungen schützen soll. In der täglichen Praxis erweisen sich Unit Tests jedoch oft, gerade bei Änderungen, als Hemmnis. Die Ursache liegt in der engen Beziehung zwischen Unit Tests und dem produktiven Code, den sie testen. Im Folgenden wird beschrieben, welche Eigenschaften Unit Tests und produktiver Code haben sollten, damit sie optimal zueinander passen. Ein Beispiel verdeutlicht diese Beziehung.

Unit Tests sind unbestrittenermaßen eine gute Sache. Auf der anderen Seite kennt aber jeder Programmierer auch die Situation, in der die Unit Tests, die ihm bei Änderungen eigentlich Probleme vom Hals halten sollen, mehr Arbeit verursachen als die beabsichtigte Änderung im produktiven Code. Worin liegt die Ursache für diese Schwierigkeiten? Dafür verantwortlich ist ein oft unterschätztes Phänomen: Unit Tests und der produktive Code, den sie testen, haben eine enge Beziehung. Der Produktivcode muss bestimmte Eigenschaften aufweisen, damit „gute“ Unit Tests dafür geschrieben werden können.

Was sind „gute“ Unit Tests?

Um diese Frage beantworten zu können, müssen wir die Begriffe „Unit“ und „Unit Test“ selbst genauer definieren. Im Allgemeinen wird eine Unit als abgeschlossener Bereich des produktiven Codes betrachtet. In einer objektorientierten Programmiersprache ist das beispielsweise eine Klasse oder eine Methode. Ein Unit Test ist dementsprechend ein Bereich des Testcodes, der eine Unit aufruft und deren Ausgaben prüft.

Leider hilft diese Definition noch nicht dabei, bessere Unit Tests zu schreiben, denn sie sagt nichts darüber aus, wie komplex so eine Unit sein darf. Eine Definition, die hier mehr Klarheit schafft, stammt von Roy Osherove [1]. Er definiert eine Unit als Teil des produktiven Codes, der eine isolierte Funktionalität realisiert. Daraus folgt seine Definition des Unit Tests als Teil des Testcodes, der eine einzelne isolierte Funktionalität im produktiven Code prüft.

Ausgehend von dieser Definition müssen Unit Tests Eigenschaften haben, die sich, ins Englische übersetzt, mit dem altbekannten Akronym RTFM zusammenfassen lassen. Ausgeschrieben bedeutet es: Readable (lesbar), Trustwothy (vertrauenswürdig), Fast (schnell) und Maintainable (wartbar). Unter [2] werden diese Anforderungen ausführlicher erörtert. Auf dem Weg zu einer besseren Qualität ist insbesondere die Wartbarkeit der Unit Tests von entscheidender Bedeutung. Diese soll daher im Folgenden näher untersucht werden. Alle anderen Eigenschaften der Unit Tests kommen erst dann zum Tragen, wenn man wirklich etwas kaputtgemacht hat. Und das sollte nicht bei jeder Änderung passieren.

„Böser“ produktiver Code?

In welcher Weise beeinflusst nun der produktive Code die Wartbarkeit seiner Unit Tests? Dreh- und Angelpunkt sind die wesentlichen Prinzipien der objektorientierten Programmierung: Single Responsibility Pattern (SRP), Separation of Concerns (SoC) und Inversion of Control (IoC). Für produktiven Code, der gegen diese Prinzipien verstößt, kann man keine Unit Tests schreiben, die ihrerseits vollständig die RTFM-Anforderungen erfüllen.

Was aber haben SRP, SoC und IoC im produktiven Code mit der Wartbarkeit der Unit Tests zu tun? Der Zusammenhang ergibt sich aus der entstehenden Komplexität der zu testenden Einheit und der Unmöglichkeit, einzelne Funktionalitäten zum Testzeitpunkt gezielt von außen zu steuern. Diese äußere Kontrolle einzelner Funktionalitäten ist notwendig, um sicherzustellen, dass diese „externen“ Units das Testergebnis nicht verfälschen, falls diese sich (absichtlich oder nicht) ändert.

Ein Beispiel

Stellen wir uns vor, ein Kunde möchte, dass wir das Regelwerk für Conway’s Game of Life implementieren, wie es bei Wikipedia [3] beschrieben ist. Betrachten wir den ersten Entwurf in Listing 1: Eine Klasse Spiel mit der Methode berechneGeneration(String karte), die aufgrund der als Parameter übergebenen Karte des aktuellen Spielfelds die neue Karte für die nächste Generation berechnet und für die Anzeige an den Aufrufer zurückgibt. In dem String, der die Karte repräsentiert, bedeutet ein „X“ eine lebende Zelle und ein „.“ eine tote.

In Listing 2 befinden sich die Tests, die für die erste Implementierung geschrieben wurden. Alle Testmethoden werden erfolgreich ausgeführt (Abb. 1).

papendieck_1

Abb. 1: Erfolgreiche Ausführung der Tests

 

class Spiel {
  String berechneGeneration(String zellenMap) {
    StringBuilder neueMap = new StringBuilder();
    String[] mapRows = zellenMap.split("[\r\n]+");
    int rowCount = mapRows.length;
    int colCount = mapRows[0].length();
    for (int row = 0; row < rowCount; row++) {
      for (int col = 0; col < colCount; col++) {
        int lebendeNachbarn = 0;
        for (int nCol = -1; nCol < 2; nCol++)
          for (int nRow = -1; nRow < 2; nRow++)
            if (!(nRow == 0 && nCol == 0)) {
              int rowIndex = 
                  (row + nRow + rowCount) % rowCount;
              int colIndex = 
                  (col + nCol + colCount) % colCount;
              char nachbar = 
                  mapRows[rowIndex].charAt(colIndex);
              if ('X' == nachbar)
                lebendeNachbarn++;
            }
        char alterZustand = mapRows[row].charAt(col);
        String neuerZustand = ".";
        if ('X' == alterZustand)
          if (2 == lebendeNachbarn || 3 == lebendeNachbarn)
            neuerZustand = "X";
        if ('.' == alterZustand)
          if (3 == lebendeNachbarn)
            neuerZustand = "X";
        neueMap.append(neuerZustand);
      }
      neueMap.append("\n");
    }
    return neueMap.toString();
  }
}

 

public class SpielTest {

  @Test public void
  berechneGeneration__feld5x5_3lebend__eineLebend() {
    Spiel spiel = new Spiel();

    String neueGeneration = spiel.berechneGeneration(
      + ".....\n"
      + "..X..\n"
      + ".....\n"
      + ".X.X.\n"
      + ".....\n");

    assertEquals("eine erwaeckte Zelle",
      + ".....\n"
      + ".....\n"
      + "..X..\n"
      + ".....\n"
      + ".....\n",
      neueGeneration);
  }

  @Test public void
  berechneGeneration__feld5x5_2lebend__alleTod() {
    Spiel spiel = new Spiel();

    String neueGeneration = spiel.berechneGeneration(
      + ".....\n"
      + ".....\n"
      + ".....\n"
      + ".X.X.\n"
      + ".....\n");

    assertEquals("alle tot",
      + ".....\n"
      + ".....\n"
      + ".....\n"
      + ".....\n"
      + ".....\n",
      neueGeneration);
  }

  @Test public void
  berechneGeneration__feld5x5_1lebend4nachbarn__stibt() {
    Spiel spiel = new Spiel();

    String neueGeneration = spiel.berechneGeneration(
      + ".....\n"
      + ".X.X.\n"
      + "..X..\n"
      + ".X.X.\n"
      + ".....\n");

    assertEquals("alleSterben aber 4 erwaeckt",
      + ".....\n"
      + "..X..\n"
      + ".X.X.\n"
      + "..X..\n"
      + ".....\n",
      neueGeneration);
  }

  @Test public void
  berechneGeneration__feld5x5_1lebend2Nachbarn__zweiLebend() {
    Spiel spiel = new Spiel();

    String neueGeneration = spiel.berechneGeneration(
      ".....\n.....\n.XX..\n...X.\n.....\n");

    assertEquals("zwei lebende Zellen",
      ".....\n.....\n..X..\n..X..\n.....\n",
      neueGeneration);
  }

  @Test public void
  berechneGeneration__feld5x5_1lebend3Nachbarn__dreiLebend() {
    Spiel spiel = new Spiel();

    String neueGeneration = spiel.berechneGeneration(
      ".....\n...X.\n.XX..\n...X.\n.....\n");
    assertEquals("vier lebende Zellen",
      ".....\n..X..\n..XX.\n..X..\n.....\n",
      neueGeneration);
  }
}

Und wenn sich was ändert?

Unser Kunde hat nun entschieden, dass das Spiel eine andere Kartengeometrie unterstützen soll. Die Regeln selbst sollen sich nicht ändern. Es sollen also weiterhin lebende Zellen mit weniger als zwei oder mehr als drei lebenden Nachbarn sterben und tote Zellen mit genau drei lebenden Nachbarn zum Leben erweckt werden.

Als Teil der neuen Anforderung soll am Beginn des Strings, der der Methode übergeben wird, eine zusätzliche Zeile mit einem Kennzeichen angefügt werden, das die Spielfeldgeometrie anzeigt. In diesem Fall müssen wir jetzt in allen Unit Tests alle Eingabewerte und alle Vergleichswerte anpassen.

In unserem einfachen Beispiel ist das keine große Sache, weil es lediglich darum geht, per Copy and Paste neue Zeilen in die Testdaten einzufügen. Doch normalerweise erfordern Änderungswünsche der Kunden tief greifendere Änderungen an den Vergleichsdaten solcher „Big Unit Tests“. In der Folge bekommen wir ein Problem, falls unsere Tests nach der Änderung nicht mehr erfolgreich sind, denn der Fehlschlag könnte drei Gründe haben:

  • Ist die neue Funktionalität (noch) nicht korrekt?
  • Wurde bestehende Funktionalität unabsichtlich geändert?
  • Oder sind schlicht die Vergleichsdaten falsch?

Da wir zunächst nicht wissen, aus welchem dieser Gründe unser Test fehlschlägt, bleibt uns nichts anderes übrig, als eine aufwendige Analyse durchzuführen.

Alternativer Lösungsweg

Wie bereits erwähnt ist die konsequente Anwendung der Prinzipien objektorientierter Programmierung der Schlüssel zu besseren, robusteren Unit Tests (eine schlechte Nachricht für Freunde der funktionalen Programmierung).

Wir müssen also feststellen, welche OOP-Prinzipien verletzt wurden, und diese Probleme beheben. Überlegen wir also einmal, was in der Klasse Spiel so alles getan wird:

  • Ermitteln der aktuellen Zelle.
  • Ermitteln der Nachbarn der aktuellen Zelle.
  • Auswertung der Zustände der Nachbarn.
  • Ermitteln des aktuellen Zustands der aktuellen Zelle.
  • Ermitteln des neuen Zustands der aktuellen Zelle aufgrund ihres Zustands und der Anzahl der Zustände der Nachbarn.

Die Änderung, die wir nun vornehmen werden, wirkt sich nur auf eine dieser Aufgaben aus, nämlich die Ermittlung der Nachbarn der aktuellen Zelle. Hätten wir eine eigene Klasse, die diese Aufgabe durchführt, müssten nur Änderungen an dieser Klasse und ihrem Unit Test vorgenommen werden. Die Programmteile, die von der neuen Anforderung nicht betroffen sind, müssten dann auch nicht angepasst werden, insbesondere nicht deren Unit Tests.

Schauen wir uns die Alternative im Codebeispiel an: Listing 3 zeigt die neue Klasse Spiel. Die dazu gehörende Testklasse findet sich in Listing 4.

class Spiel {
  private final FeldGeometrie feldGeometrie;
  private ZustandsFinder zustandsFinder;

  public Spiel(FeldGeometrie feldGeometrie,
    ZustandsFinder zustandsFinder) {
    this.feldGeometrie = feldGeometrie;
    this.zustandsFinder = zustandsFinder;
  }

  public String neueGeneration(String zellenMap) {
    feldGeometrie.setzeKarte(zellenMap);
    for (ZelleMitNachbarn zelle : feldGeometrie.zellen()) {
      String neuerZustand = neuerZustand(zelle);
      feldGeometrie.aendereAktuellesFeld(neuerZustand);
    }
    return feldGeometrie.toString();
  }

  private String neuerZustand(ZelleMitNachbarn zelle) {
    List<Zustand> nachbarn = nachbarZustaende(zelle);
    Zustand alterZustand = zustandsFinder.find(
        zelle.zustand());
    Zustand neuerZustand = alterZustand.aendern(nachbarn);
    return neuerZustand.toString();
  }

  private List<Zustand> nachbarZustaende(
    ZelleMitNachbarn zelle) {
    List<String> nachbarn = zelle.nachbarn();
    List<Zustand> nachbarZustaende = new ArrayList<>();
    for (String nachbarZustand : nachbarn) {
      nachbarZustaende.add(zustandsFinder.find(
          nachbarZustand));
    }
    return nachbarZustaende;
  }
}
public class SpielTest {
  FeldGeometrie feldGeometrie = mock(FeldGeometrie.class);
  ZustandsFinder zustandsFinder = mock(
    ZustandsFinder.class);

  @Test public void
  neueGeneration__beliebigesSpielfeld__IteratorAnfordern() {
    String zellenMap = "Kartendefinition als String";

    Spiel spiel = new Spiel(feldGeometrie, zustandsFinder);
    spiel.neueGeneration(zellenMap);

    verify(feldGeometrie).zellen();
  }

  @Test public void
  neueGeneration__beliebigesSpielfeld__KarteUebergeben() {
    String zellenMap = "Kartendefinition als String";

    Spiel spiel = new Spiel(feldGeometrie, zustandsFinder);
    spiel.neueGeneration(zellenMap);

    verify(feldGeometrie).setzeKarte(zellenMap);
  }

  @SuppressWarnings("unchecked")
  @Test public void
  neueGeneration__eineZelle__ZustaendeErmittelln() {
    String zellenMap = "Kartendefinition als String";
    ZelleMitNachbarn zelleMinNachbarn = 
      mock(ZelleMitNachbarn.class);
    Iterable<ZelleMitNachbarn> leeresSpielfeld = 
      Arrays.asList(zelleMinNachbarn);
    String zustandString = "einGueltiger Zustand";
    Zustand zustand = mock(Zustand.class);
    when(feldGeometrie.zellen())
      .thenReturn(leeresSpielfeld);
    when(zelleMinNachbarn.zustand())
      .thenReturn(zustandString);
    when(zelleMinNachbarn.nachbarn())
      .thenReturn(
        Arrays.asList("Nachbar 1", "Nachbar 2", "Nachbar 3"));
    when(zustandsFinder.find(anyString()))
      .thenReturn(zustand);
    when(zustand.aendern((List<Zustand>) any(List.class)))
      .thenReturn(zustand);

    Spiel spiel = new Spiel(feldGeometrie, zustandsFinder);
    spiel.neueGeneration(zellenMap);

    verify(zustandsFinder).find(zustandString);
    verify(zustandsFinder).find("Nachbar 1");
    verify(zustandsFinder).find("Nachbar 2");
    verify(zustandsFinder).find("Nachbar 3");
  }

  @Before public void setup() {
    Iterable<ZelleMitNachbarn> leeresSpielfeld = 
      Collections.emptyList();
    when(feldGeometrie.zellen())
      .thenReturn(leeresSpielfeld);
  }
}

Sie werden jetzt fragen: „Warum gibt es in diesem Test keine Ein- und Ausgabedaten, wie sie tatsächlich im Spiel vorkommen werden?“ Die simple Antwort ist: „Weil wir sie nicht benötigen.“ Die Funktionalität für die Auswertung der Karte wurde in eine eigene Unit verschoben. Das Spiel bekommt diese neue Unit als Abhängigkeit injiziert.

Für unseren Test haben wir diese Abhängigkeit durch ein Mock ersetzt, dessen Verhalten wir gezielt verändern. Auf diese Weise können wir die Kommunikation zwischen der getesteten Unit und dem Mock unabhängig von der tatsächlichen Implementierung der dem Mock zugrunde liegenden Unit steuern und überwachen. Aus diesem Grund können wir für den Test der Klasse Spiel Eingabedaten unabhängig von den Erfordernissen ihrer durch Mocks ersetzten Abhängigkeiten wählen.

Es ist ratsam, dies immer wenn möglich zu tun, um so die Unabhängigkeit des Tests von den anderen Teilen des produktiven Codes zu forcieren.

Die beiden Varianten des Beispiels finden Sie vollständig auf GitHub [4].

So viel Code, und wofür?

Es fällt auf, dass die anfängliche Implementierung von gerade einmal 37 Zeilen in einer einzigen Klasse auf 270 Zeilen in zehn Dateien angewachsen ist. Warum soll man so viel Aufwand betreiben? Nur weil wir im Zweifel weniger Unit Tests ändern wollen? Lohnt sich das am Ende?

Meiner Meinung nach ist dieser Aufwand durchaus gerechtfertigt. Wir können jetzt nicht nur bessere Unit Tests erstellen, der produktive Code selbst ist nun besser erweiterbar. Außerdem sollten wir nicht vergessen, dass der weitaus größte Teil des neuen produktiven Codes durch automatische Refactorings der IDE generiert wurde und eine direkte Folge der Aufteilung des Codes in mehrere Klassen und Methoden ist. Der Teil, der die eigentliche Geschäftslogik darstellt, wurde kaum geändert, nur verteilt.

Zur Veranschaulichung, welche Vorteile die neue Implementierung hat, betrachten wir erneut unsere eingangs geforderte Änderung bezüglich der Geometrie des Spielfelds. Wir erinnern uns, dass in unserem ersten Entwurf die komplette Implementierung und alle Unit Tests von der Änderung betroffen waren. Damit war das Sicherheitsnetz, als das wir Unit Tests betrachten, effektiv nicht mehr vorhanden, und das zu einem Zeitpunkt, zu dem wir die gesamte Implementierung änderten und dieses Sicherheitsnetz am dringendsten benötigten.

Im Gegensatz dazu sind mit dem neuen Entwurf weniger als fünf Prozent des bestehenden produktiven Codes von der Änderung betroffen, nämlich etwa drei bis fünf Zeilen in der (neuen) Klasse Spiel. Daraus folgt, dass wir auch nur die Unit Tests der Klasse Spiel anpassen müssen. Die Unit Tests für alle anderen Funktionalitäten bleiben unverändert erhalten und können demzufolge auch während der anstehenden Änderung uneingeschränkt ihre Aufgabe erfüllen. Die Implementierung der neuen Geometrie erfolgt in einer eigenen neuen Klasse mit eigenen neuen Unit Tests.

Für über 95 Prozent der Funktionalitäten unseres Beispiels, die sich nicht ändern, ist das Sicherheitsnetz also weiter in Funktion, während wir unsere Änderung einbauen. Die entscheidende Erkenntnis dabei: Auch in unserer ersten Implementierung hätte sich die Änderung nur auf einen geringen Teile der implementierten Funktionalität bezogen, die jedoch so im Code „versteckt“ war, dass keine Funktionalität zu Testzwecken ersetzbar und damit unabhängig testbar war. Aus diesem Grund wurde unser Sicherheitsnetz durch die anstehende Änderung komplett außer Funktion gesetzt.

Fazit

Das hier gewählte Problem war ein bewusst einfaches Beispiel. Es ging nicht darum, wie man Conway’s Spiel des Lebens am besten implementiert. Das Ziel war es, zu veranschaulichen, dass konsequente Umsetzung anerkannter Prinzipien der objektorientierten Programmierung zu leichter änderbarem produktivem Code und zu robusteren Unit Tests führt. Als Folge davon entsteht in erster Linie mehr produktiver Code, der im Gegenzug dafür besser erweiterbar ist.

Verwandte Themen:

Geschrieben von
Thomas Papendieck
Thomas Papendieck
Thomas Papendieck wurde 1966 geboren. Nach seinem Studium der Elektrotechnik war er als Zeitsoldat für die Wartung und Reparatur von Radargeräten verantwortlich. Später studierte er an der Hochschule Fulda „Angewandte Informatik“ und ist seit dem in der Softwareentwicklung, hauptsächlich in den Sprachen Java und PL/SQL tätig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: