21. September 2015
Max Wielsch
0

asynchrone JavaFX-Logik testen

Wie können asynchron verarbeitete Ergebnisse (z. B. mit javafx.concurrent. Service oder auch java.util.concurrent.CompletableFuture) im Zusammenspiel mit Business-Logik getestet werden? Die Probleme eines existierenden Ansatzes, mögliche Lösungen, sowie Vorteile und Nachteile werden in diesem Post diskutiert.

Derzeit gilt meine Leidenschaft der Entwicklung von JavaFX-Applikationen. Ein sehr verbreitetes Problem – nicht ausschließlich in der JavaFX-Entwicklung – ist das Implementieren und Testen asynchroner Funktionalität.

Die Entwicklung von Komponenten kann relativ geradlinig ablaufen, wenn man einem frontend-spezifischem Pattern wie z. B. MVVM folgt und solange die Ausführung des Codes synchron erfolgt. Ein wenig komplizierter wird es, wenn eine Klasse zu testen ist, die Abhängigkeiten auf Klassen hat, die wiederum asynchron aufgerufen werden sollten, um nicht zu blockieren. Typischerweise liefert die UI-Programmierung Beispiele dafür – siehe folgende Abbildung.

Blockierung durch langläufige Operationen

Frontend-Logik (grüne Box) enthält gewisse Use-Cases, die von verschiedenen Rollen (z. B. Nutzern) ausgelöst werden oder diese involvieren. Dabei ist die Frontend-Logik mancher Use-Cases auf die Verwendung eines Services aus dem Backend (rote Box) angewiesen. Die Ausführung im Frontend ist häufig, auch im Fall von JavaFX, an einen UI-Thread gebunden. Wenn dieser durch lang andauernde Operationen blockiert wird, stockt die Anwendung. Daher sollten alle blockierenden Operationen, wie beispielsweise Backend-Services in einem separaten Thread ausgeführt werden. Nicht selten arbeiten Service-Schnittstellen synchron, also blockierend.

Für die zu testende Frontend-Logik betrachten wir unterschiedliche Aspekte:

  1. Zunächst soll eine Komponente der Frontend-Logik auf Unit-Test-Ebene getestet werden, sodass wir sicherstellen können, dass die Use-Cases für sich korrekt implementiert sind. Um weitere Fehlerquellen auszuschließen und nur die Logik der Klasse (Unit) zu testen, müssen alle Elemente der Umgebung (die Abgängigkeiten) gemockt werden. Es interessiert dabei nicht die Art der Ausführung (asynchrone Ausführung), sondern die korrekte Reaktion auf die fachlichen Ergebnisse des Backend-Services.
  2. Darüber hinaus muss der Aspekt der Integration getestet werden. Dabei liegt der Fokus darauf, dass die Unit sich in ihre Umgebung passend einfügt und das Verhalten der Unit mit der Umgebung korrekt ist. Das heißt, die Frontend-Logik muss den asynchron aufgerufenen Backend-Service in ihren Verarbeitungsfluss richtig einbinden.

Der zweite Aspekt kann am besten durch manuelle Tests auf der Applikation oder durch automatisierte UI-Tests z. B. mit TestFX oder anderen UI-Test-Frameworks sichergestellt werden. Zuerst möchte ich jedoch meine Use-Cases in Unit-Tests sicherstellen – insbesondere bei der testgetriebenen Entwicklung.  Sofern man Einfluss auf die API des Services hat, könnte auch der Backend-Service eine API für asynchrone Ausführung erhalten. Wir betrachten diesen jedoch hier als gegeben. Für die Ausführung der Unit-Tests werden wir einen synchronen Aufruf von lang andauernden Operationen der Abhängigkeiten (Backend-Service) benötigen.

Ausgangspunkt der Überlegungen

Wie erwähnt, gibt es unterschiedliche Ansätze, die asynchrone Ausführung von Logik zu implementieren. Im Kontext von JavaFX liegt es nahe, javafx.concurrent.Service zu verwenden. javafx.concurrent.Service und javafx.concurrent.Task implementieren beide das Worker-Interface, das eine Menge Annehmlichkeiten für die Zustandssynchronisation der asynchronen Ausführung in weiteren Threads mit sich bringt.

Ein Kollege von mir hat einen sehr interessanten Ansatz zu diesem Problem auf seinem Blog vorgestellt. Insbesondere wenn javafx.concurrent.Service mit mehrfachen Aufrufen getestet werden soll, gibt es das Problem, dass seine Lösung nur für einen einzigen Service-Aufruf zu funktionieren scheint. Grund scheint ein seltsamer Thread-Check (siehe Code-Auflistung) in der Implementierung von javafx.concurrent.Service, der auch im Javadoc der Klasse genannt wird, jedoch nicht gerechtfertigt bzw. erklärt ist.

void checkThread() {
    if (startedOnce && !isFxApplicationThread()) {
       throw new IllegalStateException("Service must only be used from the FX Application Thread");
    }
}

Dies ist der Grund, warum der Ansatz nur für einfache Fälle genügt. Also Fälle, bei denen ein einziger Aufruf pro Testfall erfolgt. Wenn jedoch Use-Cases getestet werden sollten, die einen Zustand beinhalten, sind mehrere Aufrufe nötig, um diesen herzustellen oder zu erhalten. An diesem Punkt wurde klar, dass die checkThread-Logik für fehlschlagende Tests verantwortlich ist, bei denen Services involviert sind. Der Lösungsansatz meines Kollegen funktioniert für solche Fälle nicht.

Mein Use-Case

Um in der Diskussion etwas konkreter zu werden, sei in folgender Code-Auflistung eine Klasse ClassToTest gegeben, die einen Use-Case anyUseCase beschreibt. ClassToTest könnte zum Beispiel eine ViewModel-Klasse (MVVM) oder eine Controller-Klasse (MVC) sein – je nach Pattern.

public class ClassToTest {
    private Service<String> service = new Service() {
        @Override
        protected Task createTask() {
            return new Task() {
                @Override
                protected String call() throws Exception {
                    return new SomeService().longLastingOperation();
                }
            };
        }
    };
    public void anyUseCase(StringProperty resultStringProperty, IntegerProperty progressProperty) {
        progressProperty.setValue(-1);
        service.setOnSucceeded(e -> {
            resultStringProperty.setValue(service.getValue());
            progressProperty.setValue(1);
        });
        service.setOnFailed(e -> {
            resultStringProperty.setValue("An error occurred: no result");
            progressProperty.setValue(1);
        });
        service.restart(); // use restart when it was started before
    }
}

Für die Implementierung des Use-Cases wird javafx.concurrent.Service genutzt, um eine lang andauernde Operation longLastingOperation des Backend-Services SomeService asynchron auszuführen. So kann der UI-Thread der Anwendung ohne Blockaden weiterlaufen. Die lang andauernde Operation wird durch die Methode call eines durch den Service erzeugten Tasks aufgerufen.

Services werden für Prozesse genutzt, die wiederholt aufgerufen oder ausgeführt werden sollen. Dabei nutzt ein Service einen Task, um einmalig eine bestimmte Logik asynchron auszuführen. Das heißt, Services können wiederverwendet werden. Tasks nicht –  sie müssen für eine erneute Ausführung neu erzeugt werden.

Optional können die Callbacks via setOnSucceeded und setOnFailed gesetzt werden, um mit den Ergebnissen der Service-Ausführung umzugehen. Wenn die lang andauernde Operation longLastingOperation erfolgreich ausgeführt wird, soll das Ergebnis für den neuen Wert der resultStringProperty genutzt werden. Andernfalls – im Falle einer Exception – soll der Fehler dem Nutzer angezeigt werden, indem die Fehlermeldung in dieselbe Property gesetzt wird.

Der problematische Test

Hier wird der JfxRunner als Test-Runner benutzt, was dem Test einen JavaFX-Application-Thread bereitstellt, in dem dieser ausgeführt wird. Führt man den folgenden Test aus, stellt man fest, dass mehrere Aufrufe des Services zu einem Fehler wegen des Aufrufs der checkThread-Methode in der Klasse javafx.concurrent.Service führen.

@RunWith(JfxRunner.class)// Using the solution pattern given by http://blog.buildpath.de/how-to-test-javafx-services/
public class ClassToTestTest {
    @Test public void testAnyBusinessCaseCallingTwice() throws ExecutionException, InterruptedException {
        StringProperty resultProperty = new SimpleStringProperty();
        IntegerProperty progressProperty = new SimpleIntegerProperty(0);

        // first call succeeds
        CompletableFuture longLastingOperationFuture1 = newFuture(progressProperty);
        cut.execLongLastingOperation(resultProperty, progressProperty);
        longLastingOperationFuture1.get();

        assertEquals("An expensive result", resultProperty.get());
        assertEquals(1, progressProperty.get());

        // second call fails as because of {@link Service#checkThread}
        CompletableFuture longLastingOperationFuture2 = newFuture(progressProperty);
        cut.execLongLastingOperation(resultProperty, progressProperty);
        longLastingOperationFuture2.get();

        assertEquals("An expensive result", resultProperty.get());
        assertEquals(1, progressProperty.get());
    }
    private CompletableFuture newFuture(IntegerProperty progressProperty) {
        CompletableFuture completableFuture = new CompletableFuture();
        progressProperty.addListener((b, o, n) -> {
            if (n.intValue() == 1) {
                completableFuture.complete(null);
            }
        });
        return completableFuture;
    }
}

Der erste Service-Aufruf läuft ohne Probleme durch – erst der zweite Aufruf schlägt fehl.

Erste Lösungsidee

Wenn eine Klasse unit-getestet wird, ist es nötig Abhängigkeiten zu mocken. Eine „einfache“ Möglichkeit dies ohne Frameworks zu tun, ist die Abhängigkeit zu erweitern (Sub-Klasse bilden) und benötigte Methoden – entsprechend dem im Test erwarteten Verhalten – zu überschreiben.

Zunächst war der Gedanke, dass wir nur die Klasse javafx.concurrent.Task für die asynchrone Ausführung nutzen, dann gibt es kein Problem dieses Verhalten zu testen. Wir erstellen eine Sub-Klasse, die von Task erbt. Dann überschreiben wir die call-Methode, indem wir die Sichtbarkeit von protected auf public ändern. Das gibt uns die Möglichkeit den Task selbst im aktuellen Thread (Unit-Test-Thread) zu starten. Die Ausführung bleibt dann nur für Testzwecke synchron.

Problematisch ist, dass weitere wichtige Methoden, die für die Arbeit mit Tasks gebraucht werden, als final deklariert werden. Zum Beispiel ist die Methode setValue final und zusätzlich auch noch private. Dies nimmt uns jegliche Möglichkeit, die entsprechenden Methoden zu überschreiben und damit diese zu mocken.

Selbst wenn wir die final-Restriktionen in Tasks nicht hätten, wäre noch die Service-Klasse, die Tasks einfach asynchron startet. Selbst dann, wenn für den Service die Strategie des Überschreibens funktionieren sollte, hätte man immer noch das Problem, dass man eine Menge Methoden überschreiben müsste. Am Ende müsste die ganze asynchrone Service-Logik synchron implementiert werden. Da das ein enormer Mehraufwand wäre, um die Ausführung des SomeService zu testen, versuchen wir eine andere Lösung zu finden. Es stellt sich also die Frage: Wie kann die asynchrone Ausführung von Logik implementiert werden, ohne die Testbarkeit aufzugeben?

Finden einer anderen Lösung

Um die Use-Cases dennoch testen zu können, sprach ich mit anderen Kollegen, die Erfahrung damit haben, asynchrone Funktionen zu testen. Wir kamen zu dem Schluss, dass wir eigentlich nicht die asynchrone Ausführung durch javafx.concurrent.Service testen wollen, sondern die Logik der Use-Cases selbst (zumindest auf Unit-Test-Ebene).

Die folgende Lösung zeigt eine Implementierung, die auf der Idee basiert, einen Wert durch ein Callable berechnen zu lassen und dann mit dem Ergebnis von Erfolg und Fehler umzugehen. Man sieht, diese Abstraktion nutzt eine ähnliche Semantik wie Service.

public class AsyncExecution<T> {
    private Service<T> service;
    public AsyncExecution onStart(Callable<T> callable) {
        service = new Service() {
            @Override protected Task<T> createTask() {
                return new Task() {
                    @Override protected T call() throws Exception {
                        return callable.call();
                    }
                };
            }
        };
        return this;
    }
    public AsyncExecution onSucceeded(EventHandler resultHandler) {
        service.setOnSucceeded(resultHandler);
        return this;
    }
    public AsyncExecution onFailed(EventHandler failureHandler) {
        service.setOnFailed(failureHandler);
        return this;
    }
    public void start() {
        service.restart();
    }
    public T getValue() {
        return service.getValue();
    }
}

Diese Wrapper-Konstruktion zu nutzen ist einfach. Man schaue in die folgende Code-Auflistung, wo die lang andauernde Operation, welche asynchron ausgeführt werden soll, einfach als Callable Lambda-Ausdruck an die onStart-Methode übergeben wird. Wenn die Methode anyBusinessCase aufgerufen wird, wird dasselbe für Erfolgs- und Fehler-Callbacks gemacht. Danach wird die asynchrone Ausführung gestartet. Für diesen Zweck nutzt die start-Methode javafx.concurrent.Service.

public class ClassToTest {
    private final AsyncExecution<String> asyncExecution;

    public ClassToTest(AsyncExecution<String> asyncExecution) {
        this.asyncExecution = asyncExecution;
        asyncExecution.onStart(() -> 
           new SomeService().longLastingOperation());
    }
    public void anyBusinessCase(StringProperty resultStringProperty, IntegerProperty progressProperty) {
        progressProperty.setValue(-1);

        asyncExecution.onSucceeded(e -> {
            resultStringProperty.setValue(asyncExecution.getValue());
            progressProperty.setValue(1);
        });
        asyncExecution.onFailed(e -> {
            resultStringProperty.setValue(
               "An error occurred: no result");
            progressProperty.setValue(1);
        });
        asyncExecution.start();
    }
}

Die API ist der Service-API relativ ähnlich. Da alle Methoden der AsyncExecution weder (package) private noch final sind, können diese Methoden oder die komplette Implementierung gemockt werden, damit diese im Test synchron ohne einen JavaFX-Application-Thread ausgeführt werden können. Das Ergebnis ist: Der Test läuft nun auch beim zweiten Service-Aufruf durch, bis er schließlich grün ist. Außerdem ist der JFXRunner nicht mehr nötig, da wir keinen JavaFX-Application-Thread mehr benötigen.

Aus der Perspektive, Lösungen nicht immer wieder neu zu erfinden und Code schneller kennen zu lernen, könnte man argumentieren, dass dieser Ansatz bedeutet, eine neue API für asynchrone Ausführung kennen lernen zu müssen. Daher wurde auch versucht, sich möglichst an dieser zu orientieren. Nichtsdestotrotz, es gibt ähnliche APIs im JDK, die genau für das Thema gedacht sind. Warum diese nicht benutzen?

Nun, natürlich gibt es sie. Zum Beispiel gibt es die CompletableFuture-Klasse und es gibt einen Executor, die beide genutzt werden können, um die Art der Ausführung zu beeinflussen. Darum wurde auch das in einem kurzen Beispiel probiert. Nachfolgend kann eine Beispiel-Implementierung betrachtet werden, die ebenso grüne Tests produziert.

public class ClassToTest {
    public void anyBusinessCase(StringProperty resultStringProperty, IntegerProperty progressProperty) {
        progressProperty.setValue(-1);

        CompletableFuture.supplyAsync(() -> {
            try {
                return new SomeService().longLastingOperation();
            } catch (InterruptedException e) { throw new RuntimeException(e); }
        }).whenComplete((resultString, exception) -> Platform.runLater(
            () -> {
               if (exception != null)
                   resultStringProperty.setValue("An error occurred: no result");
               else
                   resultStringProperty.setValue(resultString);
               progressProperty.setValue(1);
        }));
    }
}

Diese Klasse bzw. das Konzept der CompletableFuture scheint sehr mächtig – man schaue auf die riesige Menge von verschiedenen Methoden für das Ausführen von Berechnungen und das Auswerten von deren Ergebnissen. Man kann mehrere Futures zusammenketten, um eine pipeline-artige Verarbeitung von unterschiedlichen Verarbeitungsschritten zu erreichen. In diesem Blogpost können detaillierte Beispiele betrachtet werden, die das Potential der CompletableFuture zeigen.

Ein definitiv negativer Aspekt der Handhabung dieser API ist das Exception Handling. Innerhalb der verschiedenen Verarbeitungsmethoden der CompletableFuture dürfen keine Checked-Exceptions geworfen werden. Man ist gezwungen Runtime-Exceptions zu benutzen, um Fehler in nachgelagerte Verarbeitungsschritte zu transportieren. Außerdem scheint die nachgelagerte Verarbeitung außerhalb des Aufrufer-Threads (JavaFX-Application-Thread) statt zu finden. Genau dies ist der Grund für die Kapselung der Logik in einem Platform.runLater-Aufruf im Beispiel. Die Dokumentation seitens Oracle könnte auch etwas umfänglicher sein, um die verschiedenen Möglichkeiten und das Umgehen mit der Klasse besser kennen lernen zu können.

So schlecht testbar und funktional ersetzbar die Klassen auch sein mögen, dies zeigt zu gleich die Existenzberechtigung für javafx.concurrent.Service und javafx.concurrent.Task – die automatische Synchronisation der Ergebniswerte vom Background-Thread in den JavaFX-Application-Thread.

Zusammenfassung

Wie man sehen kann, ist die Benutzung von CompletableFuture (im gegebenen Umfeld mit JavaFX-Application-Thread, Properties und Checked-Exceptions) nicht wirklich einfach und nur bedingt eleganter. Es steht die Frage gegenüber, wie elegant Service-APIs mit Checked-Exceptions sind – diese sind jedoch nicht selten anzutreffen. Ob CompletableFuture oder die JavaFX Concurrent API oder eine andere API genutzt werden sollten, hängt von den Anforderungen, der Art des Projekts oder auch den eigenen Präferenzen ab. Für Frontend-Logik, die mit JavaFX geschrieben ist, ist die Wahl der JavaFX Concurrency API offensichtlich. So viel zur Frage, welche asynchrone Implementierung gewählt werden kann.

Ein weiterer Aspekt war, eine Möglichkeit zu finden, um Frontend-Logik testen zu können, die asynchron ausgeführt werden muss. Bei der Suche nach einer Lösung für das genannte Problem wurde auch auf die Idee eingegangen, dass man javafx.concurrent.Service und javafx.concurrent.Task überschreiben bzw. erweitern könnte, um eine synchrone Ausführung zu erreichen. Das ist jedoch nicht einfach möglich und selbst wenn, wäre es relativ aufwändig. Eine komplett synchrone Implementierung, die in Tests verwendet werden kann, wäre wünschenswert.

Tatsächlich beschränkt der hier dargestellte Ansatz, eine bestimmte asynchrone Ausführung zu wrappen, die Verwendung der Features aus dem Worker-Interface (Properties für Fehlermeldungen und Fortschritt, etc.). In den Lösungsbeispielen kann man dies sehen – der Fortschritt wird manuell gesetzt. Da die meisten der Features nicht gebraucht werden, ist diese Lösung ausreichend für genau diesen Fall und weniger aufwändig. Es ist fallspezifisch ein Abwägen nötig.

Eine Zusammenfassung der Code-Auflistungen kann im Source Code auf Github betrachtet werden. In der Beispiel-Applikation findet man auch eine kleine Nutzeroberfläche, die das Verhalten und die Auswirkungen der implementierten Threading-Ansätze verdeutlicht. Es wurde versucht, einen Commit für jeden Schritt der Problemdarstellung bis hin zur Lösung zu machen.

Max Wielsch ist seit 2011 als Software-Entwickler bei der Saxonia Systems AG tätig. Derzeit beschäftigt er sich mit der Migration von monolithischen Architekturen hin zu entkoppelten Service-Architekturen im Kontext von Java-Enterprise-Applikationen. Dabei ist er von Backend bis Frontend involviert. Besondere Begeisterung empfindet er für React als Vertreter von aktuellen JavaScript-UI-Bibliotheken, die einen funktionalen Stil der Entwicklung unterstützen. In seiner Freizeit engagiert sich Max als Organisator der Java User Group Görlitz, um auch in der östlichsten Stadt Deutschlands die Vielfalt von IT-Angeboten zu erhöhen und insbesondere die Community zu unterstützen.

Facebook Google+ Xing 

TeilenTweet about this on TwitterShare on Facebook0Share on Google+0Share on LinkedIn0