Bei der Arbeit am Paper „Evolution of Functional UI Paradigms“, das beim FUNARCH-Workshop der ICFP 2025 veröffentlicht wurde, kam mir ein Gedanke zu puren Funktionen, den ich hier gern separat kurz erläutern möchte. Als Argument für pure Funktionen führen wir funktional Programmierenden gern die bessere Testbarkeit ins Feld: Pure Funktionen erfordern keine komplizierten Test-Setups und Mocks, sind deterministisch, parallelisierbar etc. In der Tendenz ist das sicherlich richtig. Pure Funktionen sind oft besser testbar. Notwendig ist dieser Zusammenhang allerdings weder in die eine noch in die andere Richtung. Es gibt pure Funktionen, die sind schlecht testbar und es gibt auch nicht-pure Funktionen, die gut testbar sind. Die Purity selbst kann also der Stoff nicht sein, der es erlaubt, Programmstücke ordentlich zu testen. Was aber ist dann dieser Stoff?

Bevor ich zum Versuch einer Antwort auf diese Frage komme, will ich kurz ausführen, weshalb der Zusammenhang zwischen Purity und Testbarkeit nicht zwingend ist.

Es gibt nicht-pure Funktionen, die gut testbar sind

Eine nicht-pure Funktion ist eine solche, deren Funktionsweise sich nicht allein durch eine Beschreibung des Zusammenhangs der Eingabe- und Ausgabewerte feststellen lässt. Das kann beispielsweise der Fall sein, wenn die Ein- und Ausgaben gar keine reinen (mathematischen) Werte sind, sondern veränderliche Objekte, also Speicherorte, an die man unterschiedliche Werte legen kann.

public class Counter {
    private int value;

    public Counter() {
        this.value = 0;
    }

    public int getValue() {
        return value;
    }
    
    public void inc() {
        this.value = this.value + 1;
    }
}

...

Counter counter = new Counter();
counter.inc();
assert counter.getValue() == 1;

Die Variable counter im unteren Teil dieses Code-Blocks bezeichnet einen Counter und dieser ist ein Speicherort mit veränderlichen int-Werten. Die Methode inc, die einen solchen Speicherort (implizit this) als Argument bekommt, ist definitiv nicht pur. Dennoch ist sie einfach zu testen. Wir erwarten, dass inc den Zähler inkrementiert und genau das können wir mit drei simplen Zeilen Test-Code überprüfen. Softwarearchitektonisch gibt es mit veränderlichem Zustand noch andere Probleme, aber die Testbarkeit ist zumindest in diesem einfachen Beispiel unproblematisch.

Es gibt pure Funktion, die schlecht testbar sind

Pure Funktionen sind andererseits nicht notwendigerweise gut testbar. Wir Active-Groupies programmieren viel UI-Code mit der Library reacl-c. Dieser Code setzt sich fast ausschließlich aus puren Funktionen zusammen und trotzdem gibt es große Teile unseres GUI-Codes, der nicht automatisiert getestet wird. Das liegt nicht dran, dass wir faul sind (Tests zu schreiben erleichtert ja die Programmierarbeit), sondern dass GUI-Code oft inhärent schlecht testbar ist. Betrachten wir folgendes Beispiel in ClojureScript.

(defn view [temp]
  (dom/div
    {:style (when (too-hot? temp)
               {:background "red"})}
    (temperature->string temp))

(assert-equal (view 22) (dom/div "22")

(assert-equal (view 183)
  (dom/div {:style {:background "red"}} "183"))

Die Funktion view ist eine Abbildung von einem Domänenwert „Temperatur“ zu einer UI-Repräsentation. Der Temperaturwert soll als Zahl in der UI auftauchen. Zusätzlich sollen „zu hohe“ Temperaturen (was auch immer das konkret bedeutet) prominenter betont werden. Hier haben wir uns dazu entschieden, diese „Betonung“ mithilfe eines roten Hintergrunds umzusetzen.

Nun ist view ja eine pure Funktion – es finden keine Veränderungen und keine Effekte statt – und wir können ja auch einfache Tests für diese Funktion schreiben. Das zeigt der untere Teil des Code-Blocks. Diese Tests sind allerdings schlecht, denn sie prüfen einen Aspekt der Implementierung ab: Die Tests fordern den roten Hintergrund ein und erlauben keine abweichenden Implementierungen derselben Idee von „Betonung“ zu hoher Temperaturen. Falls wir uns später entscheiden sollten, dass eine schönere Implementierung ein roter Rand oder ein kleines rotes Ausrufezeichen sein könnten, dann müssten wir sowohl die Implementierung als auch die Tests anpassen. Solche Tests, die ständig an die Implementierung angepasst werden müssen, sind fast wertlos.

Gute Tests prüfen, ob eine Implementierung ihre Spezifikation einhält und genau das ist bei diesem Beispiel sehr schwierig. In natürlicher Sprache könnten wir die Spezifikation für view so formulieren: Zeig die Temperatur als Text an und hebe zu hohe Temperaturen hervor. Wie kann diese Spezifikation aber so formalisiert werden, dass sie auch von automatisierten Tests geprüft werden kann?

Der Stoff, der die Testbarkeit garantiert

Diese Frage kann ich hier nicht beantworten. Im Gegenteil: Ich behaupte, dass manche Aspekte, die ein Computerprogramm behandelt, schlicht nicht formalisierbar und damit nicht automatisiert testbar sind. Mit diesem Artikel wollte ich bisher nur zeigen, dass pure Funktionen noch kein Garant für gute Testbarkeit sind. Ich behaupte stattdessen, dass dieses Verhältnis vermittelt ist durch etwas anderes. Das Beispiel mit der Temperaturanzeige wirft ein Licht darauf, was dieses Andere ist: Präzise und simple Spezifikationen.

Zunächst muss klargestellt werden, dass jedes Programmstück auf ein Ziel hin programmiert wird, das selbst nicht im Code aufgeht. Anders gesagt: Code ist nie Selbstzweck. Wenn man sich Mühe gibt, verhandelt man diese Ziele nicht bloß in endlosen Meetings, sondern schreibt sie auf. Das Ergebnis sind Spezifikationen. Diese Spezifikationen sind umso wertvoller, je einfacher (und dennoch ausreichend) und präzise sie sind.

Pure Funktionen starten in der Pole-Position, was die Einfachheit ihrer Spezifikationen betrifft. Die Abwesenheit von Veränderungen und Seiteneffekten ergibt, dass eine pure Funktion eben einzig und allein über den Zusammenhang von Ein- und Ausgabe charakterisiert werden kann. Bei nicht-puren Funktionen gibt es mehr Freiraum für Unfug. Diesen Unfug muss man als programmierende Person mit viel Disziplin im Zaum halten. Das Counter-Beispiel oben zeigt, dass das auch gelingen kann. Dieser Zähler hat eine sehr einfache und präzise Spezifikation und es fällt uns deshalb auch leicht, einen ordentlichen Test zu formulieren.

Andererseits zeigt das Temperatur-Beispiel auch, dass die Testbarkeit eben trotz puren Funktionen schief gehen kann und zwar dann, wenn beispielsweise die Spezifikation unpräzise ist. Ebenso ließen sich Beispiele finden mit präzisen aber komplizierten Spezifikationen. Auch dort ist es um die Testbarkeit nicht gut bestellt – Purity hin oder her.

In unserem FUNARCH-Paper beschreiben wir einen Weg, um mit diesen unpräzisen Spezifikation umzugehen. Anstatt aufzugeben und einfach gar keine UI-Tests zu schreiben, schlagen wir vor, dem Problem mit klassischer Softwarearchitektur-Handwerkskunst zu begegnen und Verantwortlichkeiten zu trennen. Konkret schlagen wir das funktionale Model-View-ViewModel-Pattern vor: Aspekte des UI-Codes, die präzise spezifizierbar sind, kommen in ein extra Modell (bestehend aus unveränderlichen Daten und puren Funktionen) und werden dadurch testbar. Die genuin schwierig präzisierbaren Aspekte des User-Interfaces werden damit in einen minimalen und dank des reacl-c-Programmiermodells lose gekoppelten Bereich der Anwendung gedrückt, was den manuellen und damit kostspieligen Testaufwand minimiert.