Zur Testbarkeit von puren Funktionen
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.