Groovy: Groovy und JUnit

Die Einführung automatisierter Tests ist zumindest in größeren Projekten, die mit Groovy arbeiten, ein absolutes Muss. Anders als bei Java nimmt es der Groovy-Compiler klag- und kommentarlos hin, wenn Sie beispielsweise den Aufruf einer Methode programmieren, die gar nicht existiert. Das muss er auch, denn er kann ja nicht wissen, was es zur Laufzeit aufgrund geänderter Metaklassen, umgelenkter invokeMethod()-Methoden in der Klasse selbst, nachträglich angewendeter Kategorienklassen usw. überhaupt für Methoden an einem bestimmen Typ geben kann. Dazu kommt, dass die Auswirkungen mancher der dynamischen Möglichkeiten, die Groovy bietet, schwer zu begrenzen sind. Wenn Sie etwa eine neue Metaklasse für eine vielfach genutzte Klasse registrieren, sind die Auswirkungen möglicherweise nur schwer überschaubar.

Folgerichtig unterstützt daher die Groovy-Bibliothek automatisierte Tests auf unterschiedliche Weise und macht sie dadurch angenehm handhabbar. Genau wie die Automatisierung von Build-Prozessen ist auch dies eine Fähigkeit, die sich sehr gut in Projekten einsetzen lässt, bei denen die eigentliche Implementierung nicht in Groovy erfolgt.

Dieser Abschnitt setzt grundlegende Kenntnisse der Methoden des Unit-Testings und des Test-Frameworks JUnit voraus. Mehr Informationen dazu finden Sie unter http://junit.sourceforge.net oder in dem JUnit Pocket Guide von Kent Beck (Oreilly-Verlag, englisch).

JUnit, die im Java-Umfeld sehr populäre Open-Source-Umgebung für automatisierte Unit-Tests, ist als API bereits in der Groovy-Laufzeitbibliothek enthalten, und es gibt zwei zusätzliche Basisklassen für Tests namens groovy.util.GroovyTestCase und groovy.util.GroovyTestSuite. Sie sind von den korrespondierenden JUnit-Klassen TestCase und TestSuite abgeleitet, daher können mit ihnen implementierte Testklassen ohne Weiteres im Testrunner Ihrer Entwicklungsumgebung ausgeführt werden. Ihre wichtigste Eigenschaft besteht darin, dass sie beide eine main()-Methode enthalten und dadurch auch ohne Testrunner ausführbar sind. Außerdem stellt GroovyTestCase eine Reihe zusätzlicher Prüfmethoden zur Verfügung (z.B. assertLength() für alle möglichen unterschiedlichen Typen). In Anhang ##API## finden Sie eine vollständige Liste der hinzugefügten Testmethoden.

Ebenfalls wichtig für automatisierte Tests sind Stellvertreter-Objekte (häufig als Dummies oder Mocks bezeichnet). Sie dienen dazu, andere Objekte zu simulieren, die von den zu testenden Klassen benutzt werden, sich aber nicht für den Testzweck eignen. Grund dafür kann sein, dass die die zu ersetzenden Klassen Nebenwirkungen produzieren, die nur schwer wieder rückgängig gemacht werden können, oder von bestimmten externen Bedingungen abhängig sind. Ganz typische Vertreter diese Art sind Datenbank-basierte Domain-Objekte, deren Verwendung in Tests einerseits voraussetzt, dass die Datenbank mit ganz bestimmten Testdaten gefüllt ist, andererseits aber die Datenbank in einem Zustand hinterlassen, dass sie für weitere Tests nicht mehr geeignet ist. Andere Gründe für den Einsatz von Stellvertreter-Objekten können darin bestehen, dass zu testenden Objekte einfach nur isoliert werden sollen, um Klarheit zu haben, was genau getestet wird, oder weil die Objekte, auf die die zu testenden Objekte zugreifen sollen, noch nicht fertig implementiert sind.

An einem einfachen, aber realitätsnahen Beispiel wollen wir zeigen, wie so ein automatisierter Unit-Test mit Groovy aussehen kann. In unserem Szenario gibt es eine zu prüfende Klasse namens PersonValidator, deren Aufgabe darin besteht, Plausibilitätsprüfungen an Objekten des Typs Person vorzunehmen. Sie ist in folgendem Beispiel dargestellt.

// Java
public class PersonValidator { 
  
  private Person person;
  List<String> fehler = new ArrayList<String>();
  
  private static Pattern EMAIL_PATTERN = Pattern.compile(
    "^[a-zA-Z][\\w\\.-]*[a-zA-Z0-9]@[a-zA-Z0-9][\\w\\.-]*[a-zA-Z0-9]"+
    "\\.[a-zA-Z][a-zA-Z\\.]*[a-zA-Z]$");

  public PersonValidator(Person person) {
    this.person = person;
  }
  
  public boolean check() {
    if (isEmpty(person.getName()) {
      fehler.add("Name ist leer.");
    }
    if (!isEmpty(person.getEmail()) &&
        !EMAIL_PATTERN.matcher(person.getEmail()).matches()) {
      fehler.add("Ungültige Email-Adresse");
    }
    // Weitere Prüfungen ...
    return fehler.size()==0;
  }
  
  public List<String> getFehler() {
    return fehler;
  }
  
  private static boolean isEmpty(String s) {
    return s==null || s.trim().length()==0;
  }
}

Person ist ein Interface mit zahllosen Gettern und Settern, dessen komplexe Implementierungen Datenbank-basiert sind und nur von einer Datenbankzugriffsschicht erzeugt werden:

// Java
public interface Person {
  String getName();
  void setName(String name);
  String getEmail();
  void setEmail(String email);
  // zahlreiche weitere Zugriffsmethoden
}

Der PersonValidator prüft nur, ob der Name nicht leer ist und die Email-Adresse, sofern gesetzt, eine gültige Form hat; wenn eine der beiden Bedingungen nicht erfüllt ist, schreibt er eine entsprechende Meldung in eine Fehlerliste und gibt das Ergebnis false zurück. Die zahlreichen weiteren Felder des Typs Person interessieren nicht.

Wenn Sie in dieser Situation einen isolierten JUnit-Testfall in Java schreiben wollen, haben Sie ein Problem: Die Original-Implementierung von Person steht nicht zur Verfügung, wenn Sie nicht auf die Datenbank-Zugriffsschicht zurückgreifen wollen. Eine eigene Dummy-Implementierung von Person ist sehr aufwändig, da Sie die vielen weiteren Methoden implementieren müssen, die Sie im Test gar nicht interessieren. Und jedes Mal, wenn dem Person-Interface eine weitere Methode hinzugefügt wird, müssen Sie Ihren Test anpassen, auch wenn diese neue Methode völlig irrelevant für den Test ist.

Wenn wir den Test in Groovy schreiben, haben wir es da einfacher. Wir implementieren das Interface Person einfach mit Hilfe einer Map, wie wir es in Closures gesehen haben, zum Beispiel so:

// Groovy
dummy = [:]
dummy.getName = { 'Tim' }
dummy.getEmail = { 'tim@oreilly.com' }
p = dummy as Person

Die Variable p verweist jetzt auf eine Implementierung von Person, deren Methoden ‒ soweit vorhanden ‒ die Closures in der Map sind. Der Aufruf von p.getName() und p.getEmail() liefert nun erwartungsgemäß den String "Tim" bzw. "tim@oreilly.com". Der Aufruf einer nicht in Person deklarierten Methode, zum Beispiel p.setName("O'Reilly") führt natürlich zu einer Exception, wenn sie nicht implementiert ist.

Ein einfacher Testfall auf Basis des GroovyTestCase könnte dann so aussehen:

// Groovy
import groovy.util.GroovyTestCase

class PersonValidatorTest extends GroovyTestCase {

  void testCheckPositiv() {
    def dummy = [getVorname:{'Tim'},getEmail:{'tim@oreilly.com'}]
    def pv = new PersonValidator(dummy as Person)
    assertTrue pv.check()
    assertEquals (0, pv.fehler.size())
  }

  void testCheckNegativ() {
    def dummy = [getVorname:{''},getEmail:{'tim.oreilly.com'}]
    def pv = new PersonValidator(dummy as Person)
    assertFalse pv.check()
    assertEquals (2, pv.fehler.size())
  }
}

Sie können das Programm wie jeden anderen, auf der Basis der JUnit-Klasse TestCase geschriebenen Testfall im Testrunner Ihrer Entwicklungsumgebung laufen lassen. Alternativ lässt es sich aber auch als normales Skript ohne Testrunner starten; in beiden Fällen werden die Testmethoden alle der Reihe nach abgearbeitet, und auch wenn ein Test scheitert, werden die übrigen Tests fortgeführt.

Groovy bietet noch weitere Möglichkeiten, solche Stellvertreterobjekte für Tests einfach zu erstellen. So bilden die Klassen im Package groovy.mock ein kleines Framework für Mock-Objekte, die erweiterte Prüfmöglichkeiten für Tests bieten.

Schreiben Sie konsequent Tests auf der Basis von GroovyTestsCase und GroovyTestSuite, isolieren Sie dabei die zu testenden Klassen mit Hilfe dynamische Groovy-Objekte und automatisieren Sie Build und Test mit Skripten, die den AntBuilder nutzen, so dass die Prüfungen nach jeder Programmänderung ablaufen und sofort eine Rückmeldung liefern können. So erhalten Sie mit geringstem Aufwand die zuverlässigsten Anwendungen ‒ sogar wenn diese in einer dynamischen Sprache wie Groovy implementiert sind.