Legacy Code unter Kontrolle - Folge 3, JMockit für Fortgeschrittene

Im ersten Blog-Eintrag dieser kleinen Serie habe ich erzählt, warum JMockit eine so wichtige Rolle spielt, wenn es darum geht, eine Legacy-Anwendung zu testen. Im nächsten Eintrag habe ich das wichtigste API von JMockit, das Expectations-API vorgestellt. Als Abschluss zeige ich an zwei konkreten Beispielen aus der Praxis, wie JMockit einem auch in schwierigen Fällen einen Unit-Test möglich macht.

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 schwer zu erstellen wäre.
•    Folge 3: Zwei schwierige Fälle aus der Praxis: Ein Schaltjahresproblem und ein Singleton-Factory-Lindwurm.

Recap: JMockit-Mocks

Das Grundprinzip eines JMockit-Mocks ist das Folgende: Deklariert man mittels der Annotation @Mocked eine Klasse als Mock, so wird ab sofort an keiner Stelle im System mehr der Originalcode ausgeführt. Alle Klassenmethoden sind ebenso gemockt wie alle Konstruktoren und alle Instanzmethoden. Dies gilt für neu erzeugte Instanzen ebenso wie für alle bereits im System existierenden Instanzen! Alle non-void-Methoden geben Standard-Werte wie 0, false, null, etc. zurück.
Nun kann man Expectations formulieren, d.h. Erwartungen, dass das System-Under-Test (SUT) bestimmte Methoden auf einem solchen Mock aufruft. Dies ist wichtig für verhaltensbasierte Tests.

Und man kann mittels NonStrictExpectations vorgeben, dass bestimmte Methoden eines Mocks andere als die Standard-Werte zurückgeben, ohne dass damit eine Erwartung verbunden wäre, dass bzw. in welcher Reihenfolge die Aufrufe erfolgen. Dies wird vor allem eingesetzt, um das SUT aus seiner Umgebung herauszulösen und in einen testbaren Zustand zu versetzen.

Mit dieser Funktionalität kann man nun verblüffende Tests schreiben, die ohne JMockit nur möglich wären, indem man zuerst das zu testende System anpasst – eine Änderung, die in einem Legacy-System ohne ein Sicherheitsnetz aus Unit-Tests recht heikel sein kann.

As Time goes by …

Mein erstes Beispiel ist die Hilfsklasse DateUtility, die unter anderem auch die Zahl der Tage im aktuellen Monat zurückgibt. Zu testen ist nun, ob das auch im Februar eines Schaltjahres korrekt funktioniert. Leider verwendet die Routine lastDayOfMonth() einen direkten Aufruf von new Date() (bzw. inzwischen new GregorianCalendar()), um das aktuelle Datum zu ermitteln. Und die Systemzeit lässt sich von Java aus nicht einfach setzen. Vorgefunden habe ich etwa folgenden Test:

    @Test
    public void lastDayOfMonthUsesLeapYear()
    {
        if (new Date().getMonth() == 1) {
            if (DateUtility.isLeapYear()) {
                assertEquals(29, DateUtility.lastDayOfMonth());
            }
        }
    }

Auf meinen Hinweis, dass der Schaltjahrestest aber nur alle 4 Jahre einen Monat lang ausgeführt wird, bekam ich vom Entwickler zur Antwort: „Tja, besser als gar nichts. Anders lässt sich das nicht testen!“

Gemeinsam haben wir dann unter Einsatz von JMockit folgende Tests erarbeitet:

    private static Calendar FEB_01_2012 = new GregorianCalendar(2012, FEBRUARY, 1);
  private static Calendar FEB_01_2009 = new GregorianCalendar(2009, FEBRUARY, 1);

    @Test
    public void leapYearFebruaryHas29Days()
    {
        modifySystemDate(FEB_01_2012);
        assertEquals(29, DateUtility.lastDayOfMonth());
    }

    @Test
    public void nonLeapYearFebruaryHas28Days()
    {
        modifySystemDate(FEB_01_2009);
        assertEquals(28, DateUtility.lastDayOfMonth());
    }

    private void modifySystemDate(final Calendar newDate)
    {
        new NonStrictExpectations(System.class) {{
            System.currentTimeMillis(); result = newDate.getTimeInMillis();
        }};
    }

Der Test macht sich zunutze, dass Date und GregorianCalendar intern die Methode System.currentTimeMillis() aufrufen, um die aktuelle Zeit zu bestimmen. In der privaten Hilfsmethode modifySystemDate(…) wird daher die Klasse System teilweise gemockt. Siehe dazu auch im JMockit-Tutorial den Abschnitt „4.14.2 Dynamic partial mocking“:
http://jmockit.googlecode.com/svn/trunk/www/tutorial/BehaviorBasedTesting.html#dynamicPartial

Von Fabriken und Lindwürmern

Ein anderes, in Legacy Code oft zu findendes Konstrukt ist das Factory-Singleton, gerne kombiniert mit einem Getter-Lindwurm. In meinem aktuellen Projekt findet sich z.B. an einer Stelle der Aufruf

File dir = RuntimeFactory.getInstance().getFileSystemUtilities().getUserHomeDirectory();

Hier der relevante Ausschnitt aus dem Code von RuntimeFactory:

public class RuntimeFactory {

    private static RuntimeFactory instance;

    public static RuntimeFactory getInstance() {
        if (instance == null) {
            instance = new RuntimeFactory();
        }
        return instance;
    }

    public FileSystemUtilities getFileSystemUtilities() {
        if (SystemProperties.getRuntimeInfo().isClient()) {
            return new ClientFileSystemUtilities();
        } else {
            return new ServerFileSystemUtilities();
        }
    }
}

Hier fällt einem als erstes auf, dass man aufgrund des Singleton-Patterns dem SUT nicht einfach eine andere Klasse unterschieben kann. Als zweites erkennt man, dass in getFileSystemUtilities()der Spaß in eine weitere Runde geht. Hier wird ermittelt, ob man sich auf einem Client befindet, und zwar mit Hilfe eines weiteren Factory-Singletons SystemProperties, in das man ebenso wenig eingreifen kann.

JMockit greift uns in einer solchen Situation mit einer besonderen Art von Mocks unter die Arme, sogenannten kaskadierenden Mocks. Sie unterscheiden sich von normalen Mocks nur durch ein leicht verändertes Standardverhalten bei Methoden, die Objekte liefern. Solche Methoden liefern bei einem kaskadierenden Mock nicht null, sondern ebenfalls einen kaskadierenden Mock. Und damit lässt sich nun folgende elegante Lösung stricken:

@Test
public void checkDir()
{
    new NonStrictExpectations() {
        @Cascading RuntimeFactory rf;
        {
            RuntimeFactory.getInstance().getFileSystemUtilities().getUserHomeDirectory();
            result = new File("E:\\home");
        }
    };
    assertEquals("Homedir: E:\\home", new SUT().checkDir());
}

Siehe dazu auch im JMockit-Tutorial den Abschnitt „4.15 Cascading mocks“: http://jmockit.googlecode.com/svn/trunk/www/tutorial/BehaviorBasedTesting.html#cascading

Der Test ist grün, und was nun?

Mit JMockit findet man meiner Erfahrung nach praktisch immer auch in scheinbar untestbarem Code einen Weg, eine Unit aus dem System zu isolieren und einen Test dafür zu schreiben. Das Legacy Code Dilemma „Du musst den Code ändern, um einen Test schreiben zu können. Und du musst einen Test schreiben, um den Code ändern zu können“ lässt sich so durchbrechen.

Was ist also der nächste Schritt? Das Sicherheitsnetz ist gespannt. Nun beginnt die eigentliche Arbeit! Wir ändern in kleinen Schritten den Code, so dass die Klassen und Module auch ohne „JMockit-Magie“ entkoppelt sind. In meinem zweiten Beispiel könnte das Ziel z.B. ein SUT sein, das ein UserHomeDirectory oder wenigstens die FileSystemUtilities per Dependency Injection gesetzt bekommt. Und ja, man kann mit JMockit auch einfach einen Mock für ein Interface erzeugen.

Fazit

Um Legacy Systeme wieder testbar und damit wartbar und erweiterbar zu machen, ist nach wie vor äußerst mühsam und langwierig. Aber JMockit kann Wege öffnen, die vorher versperrt waren, und so Alternativen zu der höchst riskanten und ebenfalls sehr teuren Komplett-Neuentwicklung ermöglichen. Das sollte meine kleine Blog-Serie verdeutlichen.

Und natürlich reicht JMockit alleine nicht aus. Das Refactoring von Legacy-Systemen erfordert von Entwicklern und Architekten eine hohe technische Expertise, oft detailliertes Fachwissen, sowie große Disziplin und ein hohes Qualitätsbewusstsein. Und es verlangt ein kluges Management, das einerseits Verständnis für das schwer greifbare technische Thema hat, aber andererseits den Return-on-Invest nicht aus den Augen verliert. Gerade bei Refactoring-Maßnahmen von Legacy-Systemen ist es wichtig, Fokuspunkte und Prioritäten zu setzen, Ziele zu definieren und Fortschritte sichtbar zu machen.