Legacy Code unter Kontrolle - Folge 2, Die Grundkonzepte von JMockit

In meinem letzten Blog-Eintrag habe ich erläutert, warum JMockit bei meinem aktuellen Kunden eine so wichtige Rolle spielt, wenn es darum geht, eine Legacy-Anwendung testbar zu machen und so änderbar zu halten.
In der Folge 2 will ich heute darstellen, wie man konkret Unit-Tests mit JMockit schreibt und welche Konzepte dabei eine Rolle spielen. Es entsteht ein Test für eine Stück Legacy Code, das ohne JMockit nur sehr schwer zu testen wäre.

Hier nochmal der Überblick über die ganze Serie:

  • Folge 1: Vorstellung von JMockit
  • Folge 2: Die Grundkonzepte von JMockit. Ein erster Test, der ohne JMockit nur sehr schwer zu erstellen wäre.
  • Folge 3: Zwei schwierige Fälle aus der Praxis: Ein Remote-Service-Call und eine Schaltjahresregelung.

Recap: Die Struktur eines Unit-Tests

Die meisten Unit-Tests folgen einer einfachen Struktur.

  • Vorbereiten des System-under-Test (SUT).
  • Aufruf der zu testenden Funktionalität im SUT
  • Prüfen, ob das erwartete Ergebnis erreicht ist.

Bei jedem der drei Schritte erwarten uns in einem Legacy-System z.T. erhebliche Herausforderungen, die aber oft mit Konzepten von JMockit zu meistern sind.

Schritt 1: Die Vorbereitung des SUT

Beginnen wir mit Schritt 1, der Vorbereitung des SUT. Hier ist als erstes zu klären, woraus das zu testende System aus Sicht des Tests überhaupt besteht. Typischerweise handelt es sich hier um eine einzelne Instanz einer Klasse. Schon die direkten „Collaborators“, die Objekte, auf die zu testende Klasse zugreift, gehören nicht mehr dazu. Sie müssen nur ihre Rolle spielen, damit das SUT wie gewünscht funktionieren kann.

Daraus ergeben sich zwei Herausforderungen:

  1. Erzeugen einer Instanz der zu testenden Klasse
  2. Diese Instanz in eine geeignete Umgebung (Kontext) einbetten, in der die zu testende Funktionalität ausführbar ist.

Nehmen wir als Beispiel wieder unseren hypothetischen InvoiceUpdateRecorder aus der letzten Folge. Er protokolliert neu erstelle Rechnungen und liefert unter anderem einen Report dazu. Diese Report-Erstellung, die in der Methode getInvoiceReport implementiert ist, wollen wir testen. Hier der Code unseres SUT sowie eines Collaborators, der Klasse DbConnection:

public class InvoiceUpdateRecorder {
    private DbConnection dbConnection;

    public InvoiceUpdateRecorder()
    {
        dbConnection = new DbConnection();
        // ... Viele weitere Zeilen unverständliche Initialisierung
    }

    public String getInvoiceReport() {
        String result = "";
        // ... Viele Zeilen unverständlicher Code
        List invoices = dbConnection.fetchAllInvoices();
        if (invoices.isEmpty()) {
            result = "no invoices found";
        } else{
            // Ihr wollt nicht wissen, was hier alles passiert
        }
        return result;
    }
}
public class DbConnection {
    Connection jdbcConnection;

    public DbConnection() {
    // Holen der JDBC-Connection vom globalen Connection-Manager
        jdbcConnection = JDBCConnectionManager.currentJdbcConnection();
        // weiterer Initialisierungscode
    }

    public List fetchAllInvoices() {
        List result = null;
        // JDBC-Code, um Liste aus DB zu holen
        return result;
    }

}

Beginnen wir mit einem besonders einfachen Unit-Test, der prüft, ob als Report „no invoices found“ kommt, wenn die DbConnection keine Invoices liefert. Was ist also unser SUT? Offensichtlich nur die Klasse InvoiceUpdateRecorder, ohne die Klasse DbConnection. Ganz offensichtlich gehört auch nicht JDBC-Connection der DbConnection dazu und schon gar nicht Datenbank selbst mit ihren Inhalten. Beginnen wir also mit dem Schreiben unseres Tests, indem wir das SUT erzeugen:

@Test public void newInvoiceUpdateRecorderHasNoInvoices() {
    // System-Under-Test vorbereiten.
    InvoiceUpdateRecorder recorder = new InvoiceUpdateRecorder();
}

Wenn wir diesen Test ausführen, wird uns sofort die (nicht einmal zu unserem SUT gehörende) Klasse  JDBCConnectionManager eine eigenartige Fehlermeldung um die Ohren hauen, die besagt, dass sie nicht in einem Zustand ist, dass sie eine JDBC-Connection liefern kann. Aber was tun? Ein Blick in den Konstruktur unseres InvoiceUpdateRecorder zeigt, dass dort hart kodiert eine neue Instanz der Klasse DbConnectionerzeugt wird. Und diese wiederum enthält im Konstruktor den fatalen Aufruf eines globalen Singletons, das uns entweder eine gültige JDBC-Connection oder eine Exception liefert. Wenn wir den Code des SUT nicht ändern wollen, scheitert bereits hier unser Versuch, einen Unit-Test zu schreiben. Und wir haben die zu testende Funktionalität noch nicht einmal aufgerufen!

Wie kann uns nun JMockit weiterhelfen?

Das meiner Meinung nach wichtigste Konzept von JMockit ist wird über das sogenannte Expectations APIbereitgestellt. Damit kann man bestimmte Typen zu gemockten Typen zu machen. Jeder Referenz-Typ kommt dafür in Frage, also konkrete Klassen (wie DbConnection) und Interfaces (wie z.B. List), aber auch abstrakte oder finale Klassen (wie z.B. java.lang.System), nicht aber primitive Typen wie boolean oder int.

Wenn ein Typ gemockt ist, werden alle Aufrufe aller seiner Methoden abgefangen. Es wird nicht die Originalimplementierung ausgeführt (sofern es eine gibt). Stattdessen tut der Mock einfach nichts, allenfalls liefert er noch einen Wert zurück. Standardmäßig ist das null, 0, false, EMPTY_LIST, … . Und das funktioniert für ALLE Methoden, also auch für als private, static, final oder native markierte Methoden, für geerbte Methoden, und auch für die Konstruktoren (die nun Mocks liefern). Und es funktioniert auch für alle Instanzen des Typs, für zum Zeitpunkt des Mockens bereits bestehende und für später erzeugte.

Die Deklaration eines Typs als „gemockt“ geschieht durch die Angabe der Annotation @Mocked vor einer Variablendefinition. Das ist nicht überall, sondern nur an folgenden Stellen erlaubt, die sich durch den Gültigkeitsbereich des Mocks unterscheiden:

  1. Bei der Definition einer Instanzvariable in der Testklasse.
  2. Bei der Deklaration eines Parameters für eine Testmethode (ja, das geht!).
  3. Innerhalb eines Expectation Blocks in einer Testmethode. Das ist die Variante, die uns in unserem Beispiel weiterhilft.

Das Expectations API deckt zwei wichtige Aufgaben ab: erstens man kann Erwartungen an die Interaktion zwischen dem SUT und seiner Umgebung formulieren, also etwa „Ich erwarte, dass im Test ein Konstruktoraufruf erfolgt und dann genau dieser weitere Methodenaufruf, wobei übrigens folgender Wert zurückgegeben werden soll“. Aufrufe anderer Methoden oder eine falsche Aufrufreihenfolge werden dabei als Fehler angesehen. Man verwendet für die Definition dieser Erwartungen Expectation Blocks des Typs Expectations. Daher stammt auch der Name für das API. Zweitens man kann lediglich Rückgabewerte für Methoden vorgeben. Ob überhaupt, wie oft, und in welcher Reihenfolge die Methoden aufgerufen werden, ist dabei unerheblich. Für die Definition solcher Erwartungen an das Verhalten des Mocks verwendet man Blocks des Typs NonStrictExpectations. Diese zweite Aufgabe tritt nach meiner Erfahrung wesentlich häufiger auf als die erste. Auch in unserem Beispiel hilft uns solch ein NonStrictExpectations Block weiter.

Die Syntax von Expectation Blocks

Was hat es nun mit diesen Blocks auf sich? Schauen wir uns den Block an, den wir in unserem Beispiel benötigen:

@Test public void newInvoiceUpdateRecorderHasNoInvoices() {
    …
    new NonStrictExpectations() {
        @Mocked DbConnection anyDbConnectionMock;
        {
            anyDbConnectionMock.fetchAllInvoices(); result = Collections.EMPTY_LIST;
    }};
    …
}

Syntaktisch haben wir hier die Erzeugung einer Instanz einer anonymen Subklasse der Klasse Expectations vor uns, die eine Instanzvariable und einen statischen Initializer enthält. Die so erzeugte Instanz wird dann umgehend dem Garbage Collector übergeben. Und das soll sinnvoll sein? Puh!

Für JMockit bedeutet dieser Block (grob gesagt):

  • Der Typ DbConnection ist für den Rest der Ausführung der Testmethode gemockt. D.h., ALLE Methodenaufrufe auf bereits existierenden Instanzen in der Software greifen nun nicht mehr auf den Original-Code zu, sondern auf den Mock. Ein new DbConnection() Aufruf erzeugt nun einen Mock. Statische Methoden in DbConnection sind ebenfalls gemockt.
  • Der Mock ist nicht-strikt. Aufrufe aller seiner Methoden sind im Rest des Tests beliebig oft möglich und liefern die Standard-Werte.
  • Im Initializer befindet sich der Mock im Aufzeichnungsmodus. Aufrufe und zugehörige Rückgabewerte werden aufgezeichnet. Im Beispiel wird festgelegt, dass Aufrufe der Instanz-Methode fetchAllInvoices() immer eine leere Collection liefern sollen. Achtung: anders als z.B. bei Mockito oder EasyMock müssen diese Aufrufe NICHT unbedingt auf der Instanz anyDbConnectionMock erfolgen! Jeder Aufruf, egal auf welcher Instanz, liefert nun den vorgegebenen Rückgabewert. Die Variable anyDbConnectionMock ermöglicht es uns nur

Die eigenartige Syntax hat übrigens meiner Meinung nach einige erwähnenswerte Vorteile:

  • Innerhalb des Initializers stehen die gemockten Aufrufe nackt ohne jegliche, die Lesbarkeit behindernde „Verzierungen“  da, also kein when(…).thenReturn(…) oder doThrow(…).when(…).… wie z.B. mit Mockito.
  • Die Syntax ist immer dieselbe, egal ob sich um Aufrufe von Methoden mit Rückgabe-Werten, void-Methoden, finalen Methoden oder Konstruktoren handelt.
  • In IDEs kann man den ganzen Expectations Block einfach zusammenfalten und sich so beim Lesen des Tests auf das Wesentliche konzentrieren.
  • Es ist kein zusätzliches Kommando nötig, um aus dem Aufzeichnungs- und den Abspielmodus zu kommen. Trotzdem ist der Aufzeichnunsmodus optisch und logisch deutlich getrennt vom Abspiel-Modus.

Zurück zu unserem Test, nun erweitert um den Expectations Block:

@Test public void newInvoiceUpdateRecorderHasNoInvoices() {
    // System-Under-Test vorbereiten.
    new NonStrictExpectations() {
        @Mocked DbConnection anyDbConnectionMock;
        {
            anyDbConnectionMock.fetchAllInvoices(); result = Collections.EMPTY_LIST;
    }};
    InvoiceUpdateRecorder recorder = new InvoiceUpdateRecorder();
}

Nun bekommen wir beim Ausführen den gewünschten grünen Balken, da nicht mehr der Original-Konstruktor von DbConnection aufgerufen wird, der den „bösen“ JDBCConnectionManager aufruft. Stattdessen wird einfach nur ein Mock erzeugt und in der Instanzvariablen dbConnection von InvoiceUpdateRecorder abgelegt. Damit haben wir Schritt 1, die Vorbereitung des SUT erledigt.

Schritt 2: Der Aufruf des SUT

Der zweite Schritt ist recht einfach und kurz. Wir ergänzen den Test um den Aufruf.

String result = recorder.getInvoiceReport();

Manchmal muss man bei diesem Aufruf noch einen oder mehrere Parameter-Objekte in das SUT hineinreichen. Wenn diese gemockt werden müssen, genügt es nicht, den Mock innerhalb des Expectation Blocks zu definieren, sondern man muss den Mock als Parameter in die Testmethode hineinreichen, oder gleich als Instanzvariable der ganzen Testklasse definieren.

Schritt 3: Die Überprüfung des Ergebnisses

In unserem Fall ist dies einfach. Wir haben eine Funktion aufgerufen, die keine Seiteneffekte hat, sondern einfach ein Ergebnis liefert. Dieses können wir im Test prüfen, hier bereits in der neuen, eingängigeren JUnit 4-Syntax:

assertThat(result, is("no invoices found"));

Unser Test sieht damit so aus:

@Test public void newInvoiceUpdateRecorderHasNoInvoices() {
    // Schritt 1: System-Under-Test vorbereiten.
    new NonStrictExpectations() {
        @Mocked DbConnection anyDbConnectionMock;
        {
            anyDbConnectionMock.fetchAllInvoices(); result = Collections.EMPTY_LIST;
    }};
    InvoiceUpdateRecorder recorder = new InvoiceUpdateRecorder();

    // Schritt 2: SUT aufrufen
    String result = recorder.getInvoiceReport();

    // Schritt 3: Ergebnis prüfen
    assertThat(result, is("no invoices found"));
}

Manchmal muss man allerdings auch die Interaktion des SUT mit seiner (typischerweise gemockten) Umgebung prüfen. Dafür setzt man entweder Expectations ein. Das führt allerdings dazu, dass man deutlich mehr Wissen über Implementationsdetails in den Test steckt. Ich habe es schon oft erlebt, dass der Test dann überspezifisch wird und fehlschlägt, wenn sich die Implementierung ändert, obwohl das Ergebnis, das ich eigentlich testen wollte, unverändert geblieben ist.

Das Expectations API bietet eine Fülle weiterer Features für fast alle denkbaren Fälle an. So kann man mit Hilfe von Argument Matchers vorgeben, dass z.B. ein Aufruf einer Methode mit einem beliebigen String, der „foo“ enthält, einen bestimmten Wert zurückgeben soll. Um eine Überspezifizierung eines Tests zu vermeiden, kann man nachträglich in einem Verifications Block prüfen, dass einzelne Aufrufe, die man vorher in einem NonStrictExpectations Block ermöglicht hat, stattgefunden haben.

Spiegelt sich das Ergebnis eines SUT-Aufrufs nur in einem veränderten Zustand (z.B. dem Wert der privaten Instanzvariablen secret) des SUT wider, so kann man z.B. mit folgendem Code die Prüfung dennoch vornehmen:

String result = Deencapsulation.getField(sut, "secret");
assertEquals("expected result", result);
 

Fazit

Und damit sind wir auch schon am Ende der zweiten Folge meiner Blog-Einträge zum Thema JMockit angekommen. Wir haben einen einfach lesbaren Test für ein Legacy-System vorgenommen, in dem Collaborators vom SUT selbst unkontrollierbar erzeugt und verwendet werden.  Dazu haben wir das Expectations API von JMockit eingesetzt, das inzwischen so mächtig geworden ist, dass man das noch flexiblere Mockup API, mit dem man Klassen durch Mock-Klassen teilweise ersetzen kann, nur noch in absoluten Außnahmefällen zu Hilfe nehmen muss.

Leider konnte ich trotz der Länge des Blogs nur an der Oberfläche des Expectation APIs kratzen. Weitere Details dazu liest man am besten nach unter http://jmockit.googlecode.com/svn/trunk/www/tutorial/BehaviorBasedTesting.html.

Wer sich dafür interessiert, wie JMockit sein anscheinend magisches Verhalten implementiert, der kann hier weiterforschen: es basiert auf dem Java SE5 Feature Instrumentation API (siehe http://download.oracle.com/javase/6/docs/api/java/lang/instrument/package-summary.html) und nutzt intern die ASM Library (siehe http://asm.ow2.org/), um Bytecode zur Laufzeit zu modifizieren.

Im nächsten und letzten Teil werde ich dann beispielhaft einige weitere typische Problemfälle, die in Legacy-Code vorkommen, vorstellen und eine Lösung mit JMockit präsentieren, darunter Factory-Singletons, Remote-Service-Calls und eine datumsabhängige Funktionalität.