Legacy Code unter Kontrolle Folge 1 Vorstellung von JMockit

Legacy Code unter Kontrolle Folge 1 Vorstellung von JMockit

Bei meinem aktuellen Kunden setzen wir das Tool „JMockit“ so erfolgreich ein, dass es inzwischen einen wichtigen Platz in meiner „virtuellen Werkzeugkiste“ bekommen hat. Auch wenn sich die Entwickler in meinem Team größte Mühe gegeben haben, ordentlichen Code mit einem sauberen Design zu erstellen, so schlagen wir uns nun dennoch durch inzwischen weit über 1 Million Zeilen Java-Code, aus denen uns die Fehlentscheidungen der letzten 10 Jahre „entgegenmüffeln“. Wir kämpfen mit Legacy Code. Hier helfen uns Unit-Tests. Und JMockit spielt bei der Erstellung dieser Tests eine entscheidende Rolle.

Da JMockit noch recht neu und unbekannt ist, möchte ich es Ihnen hier in einer lockeren Folge von Blog-Einträgen näherbringen:

  • Folge 1: Vorstellung?
  • 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.

Warum Unit-Tests für Legacy Code?

Michael Feathers hat in seinem meiner Meinung nach hervorragenden Buch „Working Effectively With Legacy Code“ eine wichtige Erkenntnis formuliert: es reicht nicht aus, das Degenerieren der Code-Qualität verhindern zu wollen! Dieses Bemühen wird immer unvollkommen sein. Denn trotz bester Absichten macht man gelegentlich Fehler, die sich über die Zeit ansammeln und zu einem stetigen Abnehmen der Codequalität führen. Daher gilt es, aktiv gegenzusteuern: ständig muss man Schritte hin zu einem einfacheren, verständlicheren, besser strukturierten System machen.

Eine zentrale Technik für diese Schritte ist der Einsatz von automatisierten Regressionstests. Denn bei jeder noch so kleinen Verbesserung der Struktur muss ja sichergestellt sein, dass nicht versehentlich etwas „kaputtgemacht“  wurde. Besonders wichtig sind an dieser Stelle schnell laufende, vollautomatische Tests, die das Ein-/Ausgabeverhalten der vielen, vielen kleinen Einheiten, aus denen ein System besteht, in Isolation überprüfen, die sogenannten Unit Tests. Nach jeder kleinen Verbesserung können wir diese Tests laufen lassen und so prüfen, dass wir nicht versehentlich das Verhalten verändert haben. Ein fehlschlagender Unit Test deutet auf ein lokales, klar identifiziertes Problem in einer der Einheiten hin.

Das Legacy Code Dilemma

Nur lassen sich leider aufgrund der mangelhaften Struktur der Software die einzelnen Einheiten oft nicht in Isolation testen. Und hier beißt sich die Katze in den Schwanz: Um die Struktur mit gutem Gewissen zu verbessern, werdenUnit-Tests benötigt. Und um Unit-Tests erstellen zu können, muss erst mal die Struktur verbessert werden. Das Legacy-Code-Dilemma.

Warum ist es denn so schwierig, Unit-Tests für Legacy Code zu schreiben? Wir haben doch inzwischen jahrelange Routine darin und außerdem komfortable Mocking-Frameworks wie EasyMock oder Mockito. Auch Unit-Tests, in denen Teile des Systems durch Platzhalter ersetzt werden, sind doch damit leicht möglich. Hier ein hypothetischer InvoiceUpdateRecorder, dessen Methode getInvoiceReport wir testen möchten, sowie die DbConnection, die er benötigt (die Import-Statements lasse ich aus Platzgründen aus):

public class InvoiceUpdateRecorder {
    private DbConnection dbConnection;

    InvoiceUpdateRecorder(DbConnection aDbConnection)
    {
        dbConnection = aDbConnection;
        // ... 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(Connection ourJdbcConnection) {
        jdbcConnection = ourJdbcConnection;
        // weiterer Initialisierungscode
    }

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

}

Und so sähe beispielsweise eine Testmethode mit Mockito dafür aus:

@Test public void newInvoiceUpdateRecorderHasNoInvoices() {
      // Mock für benachbarte Systemkomponente erzeugen und Verhalten vorgeben
      DbConnection mockedDbConnection = mock(DbConnection.class);
      when(mockedDbConnection.fetchAllInvoices()).thenReturn(Collections.EMPTY_LIST);

      // System-Under-Test erzeugen
      InvoiceUpdateRecorder recorder = new InvoiceUpdateRecorder(mockedDbConnection);

      // SUT arbeiten lassen
      String result = recorder.getInvoiceReport();

      // Ergebnis prüfen
      assertTrue(result.contains("no invoices found"));
}

Wir erzeugen einen InvoiceUpdateRecorder, dem wir eine gemockte Datenbank-Verbindung untergeschieben, die immer eine leere Liste von Invoice-Objekten zurückgibt. So können wir leicht den Fall herbeigeführen, der uns interessiert, ohne tatsächlich eine Datenbank-Verbindung aufbauen zu müssen. Nachdem das SUT seine Arbeit getan hat, prüfen wir, ob das Ergebnis wie erwartet aussieht.
Und in gut strukturierten Systemen, in denen sich wie in meinem Beispiel das abhängige Objekt so einfach in das SUT per Dependency Injection hineinreichen lässt, funktioniert dieser Proxy-Ansatz auch wunderbar. Leider hat er einige, im Kontext von Legacy Code besonders nachteilige Einschränkungen:

  • Es könnennur Abhängigkeiten des SUTs gemockt werden, die von außen (er)setzbar sind, denn irgendwie muss ja der im Test erzeugte Mock seinen Weg in das SUT finden. Oft erzeugen in Legacy-Systemen Klassen aber die Objekte, von denen sie abhängen, selbst, oder sie holen sie sich, indem sie statische Methoden konkreter Klassen aufrufen. Das Singleton-Pattern spielt hier oft eine unrühmliche Rolle.
  • Allgemein können mit dem Proxy-Ansatz statische, finale oder private Methoden, die das SUT aufruft, nicht gemockt werden.
  • Auch finale Klassen können nicht gemockt werden. Darunter sind auch viele Systemklassen, wie z.B. java.lang.System.


JMockit: Das Schweizer Messer unter den Test-Tools für Legacy Code

JMockit bietet eine erstaunliche Alternative ohne diese Einschränkungen. Gemockt werden hier grundsätzlich Klassen und alle ihre Instanzen, egal wer sie erzeugt. Und auch wenn die Syntax anfangs etwas gewöhnungsbedürftig ist, so ist sie doch verblüffend konsistent und mächtig.
Hier ein einfaches Beispiel, in dem wir einen etwas widerspenstigeren InvoiceUpdateRecorder testen wollen, nämlich einen, der seine DbConnection selbst erzeugt. Hier der relevante Ausschnitt aus unserem SUT:

public class LegacyInvoiceUpdateRecorder {
    private DbConnection dbConnection;

    public LegacyInvoiceUpdateRecorder()
    {
    // ACHTUNG: Wir erzeugen uns die DbConnection selbst über einen Konstruktor-Aufruf!
        dbConnection = new DbConnection();
        // ...
    }

    // getInvoiceReport() wie wie oben
}

Und hier der entsprechende Test mit JMockit, der so mit Mockito nicht möglich wäre:

@Test public void newInvoiceUpdateRecorderHasNoInvoicesWithJMockit() {
    new NonStrictExpectations() {
        @Mocked DbConnection mockedDbConnection;
        {
            // Liefere bei Aufrufen dieser Methode eine leere Liste
            mockedDbConnection.fetchAllInvoices(); result = Collections.EMPTY_LIST;
    }};

    // System-Under-Test erzeugen. Dieses erzeugt selbst seine DbConnection über
    // new DbConnection(), bekommt aber dank JMockit nur einen Mock.
    LegacyInvoiceUpdateRecorder recorder = new LegacyInvoiceUpdateRecorder();

    // SUT arbeiten lassen. Dabei wird die Methode fetchAllInvoices aufgerufen
    String result = recorder.getInvoiceReport();

    // Ergebnis prüfen
    assertTrue(result.contains("no invoices found"));
}

In diesem Test geschieht folgendes: Zunächst wird für die Dauer des Tests die Klasse DbConnectiongemockt. Alle Aufrufe von Konstruktoren von DbConnection liefern nun Mocks. So auch der hier nicht sichtbare Aufruf innerhalb des Konstrukturs von InvoiceUpdateRecorder. Außerdem liefern nun alle Aufrufe von Klassen- oder Instanzmethoden von DbConnection Default-Werte: null, false, 0,… . Nur fetchAllInvoices() liefert das explizit vorgegebene Ergebnis (Bitte nicht stören lassen durch die eigenartige Syntax, in der diese Vorgabe erfolgt. In der nächsten Folge dieser kleinen Serie erkläre ich, was es damit auf sich hat und warum das gar keine so schlechte Idee ist). Der Rest entspricht 1:1 dem vorigen Beispiel: wieder rufen wir das SUT auf und prüfen das Ergebnis.

Für erste eigene Experimente mit JMockit empfehle ich die Lektüre des Getting-Started Artikel auf der JMockit-Homepage unter http://jmockit.googlecode.com/svn/trunk/www/gettingStarted.html.


Mein Fazit

Die Ausrede „Dieser Code ist untestbar!“ gilt mit JMockit nicht mehr. In den allermeisten Fällen lassen sich Abhängigkeiten in Java-basierten Legacy-Systemen damit aufbrechen. So war es uns in vielen Fällen möglich, die für eine verantwortliche Umstrukturierung erforderlichen Unit-Tests zu erstellen, ohne in den eigentlichen Code des SUT eingreifen zu müssen Dank JMockitkonnten wir tatsächlich den langen, mühsamen Weg aus der Legacy-Code-Hölle in Angriff nehmen.

In der nächsten Folge schauen wir uns dann einige zentrale Konzepte von JMockit und seine verblüffende Syntax etwas näher an.