Zukunftsfähigkeit IV – Wandelbarkeit/Testbarkeit

Alles könnte gut sein in Sachen Zukunftsfähigkeit, wenn Code korrekt und verständlich ist – wäre da nicht die Fehlbarkeit des Menschen. Bei aller Gewissenhaftigkeit werden Fehler und Irrtümer vorkommen. Auch das beste Feedback wird nicht nur Code von 100%iger Qualität zum Anwender lassen. Bugmeldungen und Nachbesserungswünsche kommen garantiert – allerdings hoffentlich in deutlich geringerer Menge als ohne engmaschiges und umfassendes Feedback. Und dann noch die Wünsche nach ganz Neuem.

Code ist also ständiger Änderung unterworfen. Eingriffe in Logik stellen ein ständiges Risiko dar, „dass etwas verrutscht“ oder nicht zum wunschgerechten Verhalten führt. Zukunftsfähigkeit ist per definitionem also nicht einmal hergestellt, sondern muss sich ständig bewehren.

Nicht nur ist daher unsicher, wo Code morgen angefasst werden muss. Es ist daraus folgend auch unsicher, wo morgen Code automatisiert getestet werden muss. Denn Veränderungen am Code ziehen selbstverständlich neue Tests nach sich. Entweder hat sich ja herausgestellt, dass bisherige Tests einen Bug haben durchschlüpfen lassen. Oder es existieren noch keine Tests für neues Verhalten.

Verständlichkeit hilft beim Auffinden der Stelle, die morgen angefasst werden müssen zum Bug Fixing oder für Neuerungen.

Darüber hinaus braucht zukunftsfähiger Code jedoch noch eine weitere Eigenschaft…

Testbarkeit als Aspekt der Wandelbarkeit

Um Veränderungen punktgenau einbringen und anschließend automatisiert überprüfen zu können, muss Code auch noch testbar sein.

Wandelbarkeit ist also die Kombination aus Verständlichkeit und Testbarkeit.

Testbarkeit drückt aus: Ein Verhaltensaspekt existiert als klar abgegrenzte Einheit und wurde automatisiert auf Korrektheit überprüft.

Vielleicht ist der Verhaltensaspekt sogar mit bleibenden Tests versehen, wie sie Maturität und Regressionsfreiheit fordern. Aber wenn nicht, dann können solche Tests jederzeit leicht nachgerüstet werden, falls Bedarf für eine detailliertere Korrektheitsüberprüfung entsteht.

Duplikate vermindern die Testbarkeit

Schwer zu testen ist Code, wenn dasselbe oder nahezu dasselbe an mehreren Orten steht. Um „dasselbe“ zu testen, müssten dann ja mehrere Tests geschrieben werden.

Codeduplikate stehen der Verständlichkeit nicht unbedingt im Wege, gelegentlich verbessern sie sie sogar. Die Testbarkeit wird durch Duplikate jedoch verringert. Für einen Aspekt kann dann ja nicht nur an einem Ort eine „Testsonde angelegt werden“.

Aufgabe des Reviews ist es daher zu erkennen, ob derselbe oder sehr ähnlicher Code an mehreren Stellen der Codebasis auftaucht. Ist das der Fall – widerspricht der Code als dem DRY-Prinzip -, sollte er sehr wahrscheinlich an einem Ort in Form einer geeigneten Abstraktion zusammengeführt werden. Dadurch wird zwar eine Abhängigkeit an den bisherigen Nutzungsorten aufgebaut – doch das ist ein anderes Thema und wird im Review getrennt behandelt.

Duplikate können im Produktionscode oder im Testcode vorkommen. Im Testcode sind sie zwar verzeihlicher – Tests können für größere Unabhängigkeit etwas „feucht“ sein -, doch letztlich sollte auch dort auf Einhaltung von DRY geachtet werden. Schließlich unterliegen auch Tests Veränderungen, die umso schwerer fallen, je weniger DRY der Code ist.

Hier ein Beispiel für „feuchten“ Testcode:

[Test]
public void Load_with_empty_lines() {
    var sut = new FileProvider();
    var result = sut.Load("numbers_with_empty_lines.txt");
    Assert.AreEqual(new[] { 0, 1, 0, 2, 0, 0, 3 }, result);
}

[Test]
public void Load_with_whitespace() {
    var sut = new FileProvider();
    var result = sut.Load("numbers_with_whitespace.txt");
    Assert.AreEqual(new[] { 1 }, result);
}

Das Muster, die Wiederholung liegt auf der Hand. Beide Tests unterscheiden sich nur in Input und Output. Das lässt sich „trocken“ so knapper formulieren:

[TestCase("numbers_with_empty_lines.txt", new[] { 0, 1, 0, 2, 0, 0, 3 })]
[TestCase("numbers_with_whitespace.txt", new[] { 1 })]
public void Load(string filename, int[] expected) {
    var sut = new FileProvider();
    var result = sut.Load(filename);
    Assert.AreEqual(expected, result);
}

Ein solchermaßen datengetriebener Test ist aber natürlich nur möglich, wenn der Testframework ihn bietet und die Testdaten sich im Fall von C# im Attribut auch notieren lassen. Das ist nur für Konstanten der Fall.

Außerdem ist zu beachten, dass die Bedeutung des Tests nicht verlorengeht. Es gibt nur noch eine Testmethode, deren Name etwas über die Testfälle aussagen kann. Im Verein mit den Dateinamen mag das hier ausreichen, in anderen Szenarien müssten zusätzliche Informationen hineingereicht werden.

Ansonsten probat und auch anwendbar außerhalb von Testcode ist die Extraktion von Duplikaten in eigene Module:

[Test]
public void Load_with_empty_lines() {
    Assert.AreEqual(new[] { 0, 1, 0, 2, 0, 0, 3 }, Try_Load("numbers_with_empty_lines.txt"));
}

[Test]
public void Load_with_whitespace() {
    Assert.AreEqual(new[] { 1 }, Try_Load("numbers_with_whitespace.txt"));
}

private int[] Try_Load(string filename) {
    var sut = new FileProvider();
    return sut.Load(filename);
}

Die Tests sind nun auf die Differenz fokussiert, in Try_Load() ist das Gemeinsame zusammengefasst.

Während Tools für die Beurteilung der Verständlichkeit von Code mit Vorsicht zu genießen sind, stellen sie beim Auffinden von Duplikaten jedoch eine unschätzbare Hilfe dar. Versuchen Sie es ruhig einmal mit einem statischen Analysewerkzeug ihrer Wahl.

Funktionale Abhängigkeiten vermindern die Testbarkeit

Durch Duplikate entsteht höherer Testaufwand über eine größere Menge an Tests. Funktionale Abhängigkeiten hingegen erhöhen den Testaufwand durch die Notwendigkeit zusätzlicher Infrastruktur.

Funktionale Abhängigkeiten sind ein Grundübel schlecht wandelbaren Codes. Nicht nur verschlechtern sie die Verständlichkeit durch Wechsel des Abstraktionsniveaus, sie machen es auch noch schwer, Logik zu testen.

Überall dort, wo Logik einer Domäne weitere Logik über einen Funktionsaufruf anspricht, um Verhalten herzustellen, liegt eine funktionale Abhängigkeit vor. Hier ein Beispiel dafür:

public int SumUpFileContent(string filename) {
    var sum = 0;
    var lines = File.ReadAllLines(filename);
    foreach (var line in lines)
        sum += Parse_line_to_number(line);
    return sum;
}

private int Parse_line_to_number(string line) {
    if (!string.IsNullOrWhiteSpace(line))
        return int.Parse(line);
    else
        return 0;
}

Methode SumUpFileContent() enthält Logik und (!) ruft darin eine andere Methode der Domäne auf: Parse_line_to_number(), die wiederum Logik enthält. Die aufrufende ist von der aufgerufenen abhängig.

Das bedeutet, die Logik in SumUpFileContent() kann nicht ohne die Logik von Parse_line_to_number() getestet werden. Wenn beim Test ein Fehler auftritt, ist deshalb nicht klar, ob den die aufrufende oder aufgerufene Methode verursacht.

Sie können sich vorstellen, dass diese Unklarheit mit der Tiefe des Baumes funktionaler Abhängigkeit wächst. Funktionale Abhängigkeiten erschweren die Testbarkeit also erheblich. Und die Verständlichkeit, denn die Abstraktionsniveaus sind unterschiedlich. Logik auf Level 1 oder 2 trifft auf Funktionsaufrufe auf Level 3, 4 oder 5.

Dennoch finden Sie funktionale Abhängigkeiten allerorten im Code. Sie sind ganz normal. Sie scheinen ja auch unvermeidbar. Wie sonst sollte Logik strukturiert sein, wenn Methoden nur begrenzt Logik enthalten dürfen für die Verständlichkeit? Ganz davon zu schweigen, dass auch unverständliche Funktionen mit tausenden Zeilen starke funktionale Abhängigkeiten besitzen.

Funktionale Abhängigkeiten sind also die Norm. Deshalb gibt es auch schon lange eine Empfehlung, wie mit ihnen umzugehen ist, um die Testbarkeit nicht zu kompromittieren. Die Lösung lautet: Dependency Inversion (DI) oder Inversion of Control (IoC).

Wenn der aufrufende Code zumindest zur Entwicklungszeit nicht direkt von einer Implementation abhängig ist, sondern von einer Abstraktion, dann kann die für Testzwecke ersetzt werden durch eine Attrappe. Das könnte z.B. so aussehen:

public class Aggregator {
    readonly Func<string, int> parse;

    public Aggregator() => this.parse = this.Parse_line_to_number;
    internal Aggregator(Func<string,int> parse) => this.parse = parse;

    public int SumUpFileContent(string filename) {
	    var sum = 0;
	    var lines = File.ReadAllLines(filename);
	    foreach (var line in lines)
	        sum += parse(line);
	    return sum;
    }
...

Die Abstraktion hier ist allerdings kein Interface, wie üblich, sondern lediglich eine Funktion. Aber das tut der Anwendung des Prinzips keinen Abbruch. Die Attrappe kann über den Konstruktor injiziert werden (ctor injection). Geschieht das nicht, wird die klasseneigene Implementation benutzt.

Ein Test, der alles außer der Logik testen möchte, von der die Methode funktional abhängig ist, sähe dann so aus:

[Test()]
public void Reading_and_adding() {
    var sut = new Aggregator(_ => 1);
    var result = sut.SumUpFileContent("numbers.txt");
    Assert.AreEqual(4, result);
}

Die injizierte Attrappe liefert konstant 1 zurück, so dass effektiv lediglich die Zahl der Zeilen in der Datei gezählt wird. Eine Differenzierung zwischen Leerzeilen und mit Zahlen gefüllten findet natürlich nicht statt; das ist Aufgabe der Funktion, die nun ausgeblendet ist.

Mit Austausch der eigentlichen Implementation durch eine Attrappe wird die funktional abhängige Logik also testbar. Irgendwie. Denn schön ist das nicht. Erstens macht es zusätzlichen Aufwand: die Injektion muss überhaupt möglich gemacht werden, eine Abstraktion muss her und dann auch noch die Attrappe. Zweitens ist bei einem Fehler immer noch nicht gleich klar, ob der durch die Logik vor dem Aufruf oder nach dem Aufruf der Attrappe verursacht wird.

Wenn Sie das Szenario weiterspinnen, können Sie sich vorstellen – wenn Sie es nicht schon selbst erlebt haben -, dass DI/IoC die Testbarkeit ganz praktisch nicht wirklich verbessern. Die Prinzipien samt zugehöriger Mock-Frameworks sind reparieren nur an einem Symptom herum, nicht an der Ursache.

Das Symptom sind die Aufrufe von Funktionen, die Ursache ist die funktionale Abhängigkeit bzw. die Vermischung von Abstraktionsniveau 1/2 mit 3 oder höher.

Einen richtigen Sprung nach vorn macht die Testbarkeit hingegen, wenn auf funktionale Abhängigkeiten verzichtet wird. Das bedeutet: Domänenfunktionen enthalten entweder Logik (Abstraktionslevel 1 und 2) oder sie rufen ausschließlich andere Domänenfunktionen.

Code, der so strukturiert ist, folgt dem Integration Operation Segregation Principle (IOSP):

  • Funktionen, die nur Logik enthalten, heißen Operationen.
  • Funktionen, die nur Operationen aufrufen, heißen Integration. (Und können natürlich auch andere Integrationen aufrufen.)

Parse_line_to_number() oben ist eine Operation, SumUpFileContent() hingegen ist keine Integration, weil die Funktion Logik enthält und den Aufruf einer Operation.

Wie könnte die Summierung denn aber nach IOSP aussehen? Zum Beispiel so:

public class Aggregator {
    public int SumUpFileContent(string filename) {
        var lines = Load_text(filename);
        var numbers = Extract_numbers(lines);
        return Sum(numbers);
    }

    private IEnumerable<string> Load_text(string filename) {
        return File.ReadAllLines(filename);
    }

    private IEnumerable<int> Extract_numbers(IEnumerable<string> lines) {
        foreach(var line in lines)
            if (!string.IsNullOrWhiteSpace(line))
                yield return int.Parse(line);
    }

    private int Sum(IEnumerable<int> numbers) {
        var sum = 0;
        foreach (var n in numbers)
            sum += n;
        return sum;
    }
}

SumUpFileContent() ist jetzt eine Integration. Die Funktion ist nicht mehr funktional abhängig von anderen. Sie enthält keine Logik. In ihr kann nichts mehr schief gehen. Bugs sitzen vor allem in Logik.

Reine Integration muss daher nicht getestet werden – es sei denn als öffentliche Funktion, also an der Oberfläche eines Modules im Rahmen eines Akzeptanztests oder als Pfeilertest zur Absicherung der Regressionsfreiheit.

Und wenn eine Integration getestet werden sollte, dann müssen die aufgerufenen Funktionen nicht sofort durch Attrappen ausgetauscht werden. Dann handelt es sich bewusst um einen Integrationstest, der das Zusammenspiel der Teile überprüfen soll.

Operationen hingegen müssen getestet werden, insbesondere im Rahmen von Gerüsttests. Dort „spielt die Musik“. Dort wird Verhalten hergestellt. Dort kann etwas schief gehen.

Zum Glück ist das jedoch sehr einfach. Operationen sind per definitionem funktional unabhängig. Also müssen keine Attrappen aufgebaut werden. Um zum Beispiel zu prüfen, ob die Summierung korrekt funktioniert, kann Sum() allein getestet werden:

[Test, Description("Gerüttest")]
public void Sum() {
    var sut = new Aggregator();
    var result = sut.Sum(new[] { 1, 2, 3 });
    Assert.AreEqual(6, result);
}

Dafür muss Sum() zwar sichtbar gemacht werden, doch das ist ein kleiner Preis, der zu bezahlen ist, um „mal eben“ die Logik zu überprüfen. Viel schwieriger wäre es, wenn auch noch Attrappen gebaut werden müssten.

Funktionale Abhängigkeiten reduzieren die Testbarkeit drastisch. Funktionale Abhängigkeiten machen Software schwer verständlich. Unsere klare Empfehlung lautet daher: Verzichten Sie auf funktionale Abhängigkeiten! Folgen Sie dem IOSP.

Testbarkeit schrittweise herstellen

Testbarkeit ist nicht binär vorhanden oder nicht. Logik lässt sich graduell schlechter bzw. besser testen.

Erste Voraussetzung ist ihre Einmaligkeit. Zweite Voraussetzung ist ihre Unabhängigkeit.

Aber auch wenn Logik nur in einer Operation existiert, ist die Testbarkeit noch nicht gleich maximal. Es mögen weitere Schritte nötig sein, um sie zu verbessern.

Freistellung

Logik ist nur testbar, wenn sie freigestellt ist. Sie muss für sich allein adressierbar sein als Funktion. Sonst kann kein Test als Sonde unmittelbar angelegt werden.

Operationen machen zwar funktional unabhängige Logik testbar, doch genügt diese Logik auch dem SRP? Wenn nicht, dann sind die Tests sehr pauschal. Sie überprüfen nicht nur einen Aspekt, sondern mehrere. Schlagen sie fehl, stellt sich die Frage, welcher Aspekt daran Schuld ist.

Die folgende Funktion ist zwar eine Logik, doch hat sie auch nur eine Verantwortlichkeit?

internal class FileProvider {
    public int[] Load(string filename) {
        var lines = System.IO.File.ReadAllLines(filename);
        var numbers = new List<int>();
        for (var i = 0; i < lines.Length; i++)
            if (!string.IsNullOrWhiteSpace(lines[i]))
                numbers.Add(int.Parse(lines[i]));
        return numbers.ToArray();
    }
}

Aus einer gewissen Flughöhe betrachtet, entspricht Load() dem SRP: Die Funktion beschafft die Zahlen aus einer Textdatei. Doch wenn Sie genau hinschauen, hat auch diese Funktionalität noch zwei Aspekte.

Der eine ist „die Bedienung“ eines API, hier der Umgang mit Textdateien mittels technischer Funktionen aus dem Namensraum System.IO. Das umfasst zwar derzeit nur eine Zeile Code, weil der .NET Framework viele Details kapselt. Doch das könnte auch ganz anders aussehen. Was zum Beispiel, wenn die Datei nicht existiert und dieser Fall soll gezielt behandelt werden? Oder was, wenn der Text nicht schon zeilenweise geladen würde?

Wie an die einzelnen Zeilen des Textes in einer Datei kommen, ist die eine Sache. Eine ganz andere ist es, aus den Zeilen des Textes die Zahlen zu extrahieren. Wie müssen die Zeilen aufgebaut sein, damit das klappt? Dürfen sie leer sein, wie sind sie mit Zahlen gefüllt? Neben der Textbeschaffung ist die Textanalyse ein zweiter Aspekt.

Ein separater Test des Aspekte ist nicht möglich. Es lässt sich nicht nur prüfen, ob mit dem API korrekt umgegangen wird; es lässt sich nicht isoliert prüfen, ob ein Text korrekt analysiert wird. Das drückt sich in Tests z.B. dadurch aus, dass immer gegen eine Datei getestet werden muss. Wie umständlich, eine Datei anlegen zu müssen, um Varianten der Textstruktur durchzuspielen.

Wie anders sieht dagegen diese Klasse aus:

internal class FileProvider {
    public int[] Load(string filename) {
        var lines = Load_text(filename);
        return Parse(lines).ToArray();
     }

    private IEnumerable<string> Load_text(string filename) {
        return System.IO.File.ReadAllLines(filename);
    }

    private IEnumerable<int> Parse(IEnumerable<string> lines) {
	foreach (var line in lines)
	     yield return Parse(line);
    }

    private int Parse(string line) {
	if (!string.IsNullOrWhiteSpace(line))
	     return int.Parse(line);
	else
	     return 0;
    }
}

Hier sind alle Aspekte freigestellt in eigene Funktionen. Die Beschaffung des Textes ist getrennt testbar von der Analyse und sogar die Gesamtanalyse unterschieden von der Analyse einer Zeile. Aus der ursprünglichen Operation ist dabei eine Integration geworden.

Hier ist deutlich erkennbar, wie die Testbarkeit die Wandelbarkeit befördert. Sollte sich etwas bei der Benutzung des API verschieben, ist klar, in welcher Funktion Änderungen ganz gezielt vorgenommen werden müssen. Oder sollte sich der Textaufbau ändern, wird das sehr wahrscheinlich Auswirkungen auf auch nur eine Funktion haben. Tests der Veränderungen können dann punktgenau angebracht werden.

Sichtbarkeit

Logik freigestellt ohne Abhängigkeiten in der Tiefe, in der Vertikalen in Form von Operationen und auch noch freigestellt in der Horizontalen, d.h. getrennt von anderen Aspekten ist grundsätzlich gezielt testbar, d.h. unabhängig von anderer Logik.

Aber ist die Logik auch leicht erreichbar? Das ist sie nur, wenn sie erstens öffentlich ist. Tests von privaten Methoden sind nicht ohne weiteres möglich. Manchmal helfen Tools, manchmal muss die Sichtbarkeit jedoch manuell erweitert werden.

Geringe Sichtbarkeit verringert zwar die Testbarkeit, andererseits erhöht sie die Entkopplung. Beide Ziele sind auszubalancieren. Nur wegen der Testbarkeit Funktionen öffentlich zu machen, ist sicher nicht die Empfehlung. Gelegentlich mag das angezeigt sein, im Allgemeinen ist es aber angemessen, sie lediglich während der konkreten Arbeit an ihnen temporär öffentlich zu machen und mit Gerüstests zu versehen. Nach getaner Arbeit werden diese Tests dann gelöscht und die Sichtbarkeit zurückgenommen. So wie es sich für Details gehört.

Beschaffung

Nur weil Operationen eine fokussierte Verantwortlichkeit haben und öffentlich sind, ist die Testbarkeit allerdings nicht automatisch hoch. Im Test sind sind die Funktionen ja auch noch zu beschaffen. Was muss dafür getan werden?

Stehen sie als statische Funktion einfach so zur Verfügung oder muss zuerst eine Objektinstanz erzeugt werden?

Statische Funktionen sind in den letzten Jahren in Ungnade gefallen, weil sie die Testbarkeit zu behindern schienen. Sie lassen sich nicht in Interfaces abstrahieren, um sie während eines Tests zu injizieren. Statische Methoden stehen DI/IoC im Wege.

Das mag sein, doch DI/IoC sind ja nur Symptombehandlungen. Wenn das darunter liegende Problem der funktionalen Abhängigkeit gelöst ist, verlieren die Prinzipien an Bedeutung. Aus unserer Sicht machen statische Methoden keine prinzipiellen Problem und erhöhen die Testbarkeit sogar.

Statische Methoden sind sogar vorzuziehen. Wir weichen von diesem Default nur ab, wenn andere Kräfte das nahelegen. Sehen Sie, wie die obige Klasse mit statischen Methoden an Testbarkeit gewinnt:

internal class FileProvider {
    public int[] Load(string filename) { ... }

    private IEnumerable<string> Load_text(string filename) { ... }

    internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
	foreach (var line in lines)
	     yield return Parse(line);
    }

    private static int Parse(string line) {
	if (!string.IsNullOrWhiteSpace(line))
	     return int.Parse(line);
	else
	     return 0;
    }
}

Um die Analyse zu testen, sind jetzt keine Objektinstanzen mehr nötig:

[Test]
public void Parse_with_empty_lines() {
    var result = FileProvider.Parse(new[] { "", "1", "", "2", "" });
    Assert.AreEqual(new[]{0,1,0,2,0} , result.ToArray());
}

Die FUT (Function Under Test) steht sofort zur Verfügung. Der Test kann sehr knapp ausfallen.

So wird auch deutlich, dass hier noch Verbesserungspotenzial schlummert. Denn warum sollte die Analyse Nullen für leere Zeilen zurückliefern? Das macht in Bezug auf das Problem keinen Sinn, sondern ist, wenn Sie genau hinschauen, ein Ergebnis übereifriger Aspekttrennung. Besser ist es so:

internal class FileProvider {
	public int[] Load(string filename) {
            var lines = Load_text(filename);
	    return Parse(lines).ToArray();
	}

        private IEnumerable<string> Load_text(string filename) {
             return System.IO.File.ReadAllLines(filename);
        }

	internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
	    foreach (var line in lines)
                if (!string.IsNullOrWhiteSpace(line))
                     yield return int.Parse(line);
	}
}

Die Operation Parse(string) ist in Parse(IEnumerable<string>) aufgegangen. Nun muss kein unnötiger Wert mehr für den Fall einer leeren Zeile zurückgegeben werden.

Auf Akzeptanztests der Gesamtfunktionalität zur Summierung von Zahlen in einer Datei hat diese Veränderung im Detail keinen Einfluss. Doch der Test der verbleibenden Parse()-Methode fällt leichter aus wie auch der Akzeptanztest von Load(). Statische Methoden machen es möglich.

Abhängigkeit von Konstanten

Öffentliche statische Methoden sind leicht testbar. Eigentlich. Denn die schöne Testbarkeit kann durch Abhängigkeiten verhagelt werden. Operationen sind zwar nicht mehr funktional abhängig, doch Abhängigkeit gibt es nicht nur von Funktionen.

Abhängig kann Logik auch von Konstanten sein. Das ist besonders sichtbar, wenn Daten, die eigentlich unveränderlich sind, einem einfachen Test im Wege stehen.

Angenommen, die zu summierenden Daten werden immer in der Datei numbers.txt angeliefert. Dann wäre es legitim, diesen Namen in der Logik zu hinterlegen:

public class Aggregator {
    public int SumUpFileContent() {
        var lines = Load_text("numbers.txt");
        var numbers = Extract_numbers(lines);
        return Sum(numbers);
    }
...

Für den Produktivbetrieb ist das kein Problem, aber für den Test. Denn jeder Test von SumUpFileContent() müsste Testdaten in einer Datei dieses Namens zur Verfügung stellen. Das ist umständlich.

Testrelevante Konstanten sollten daher zumindest für Tests variabel gemacht werden. In diesem Fall könnte das durch einen Methodenparameter geschehen:

public int SumUpFileContent() => SumUpFileContent("numbers.txt");
internal int SumUpFileContent(string filename) {
    var lines = Load_text(filename);
    var numbers = Extract_numbers(lines);
    return Sum(numbers);
}

Das eigentliche Arbeitspferd wird eine Methode mit einem Parameter und die öffentliche Methode leitet dahin unter Angabe des konstanten Dateinamens. Im Test wird das Arbeitspferd bemüht, um leichter unterschiedliche Szenarien zu durchlaufen, z.B.

[Test()]
public void Akzeptanztest_mit_Leerzeilen() {
    var sut = new Aggregator();
    var result = sut.SumUpFileContent("numbers_with_empty_lines.txt");
    Assert.AreEqual(6, result);
}

Alternativ kann eine Konstante auch über den Konstruktor injiziert werden:

public class Aggregator {
    private string filename;

    public Aggregator() : this("numbers.txt") {}
    internal Aggregator(string filename) => this.filename = filename;

    public int SumUpFileContent() {
        var lines = Load_text(this.filename);
	var numbers = Extract_numbers(lines);
	return Sum(numbers);
    }
...

Der öffentliche Konstruktor ist unverändert. Aber ein neuer für Tests nimmt den Dateinamen als Parameter und stellt ihn als Feld allen Methoden zur Verfügung.

Da es sich bei SumUpFileContent() schon um eine Instanzmethode handelt, wäre das eine legitime Möglichkeit. Bei einer statischen Methode wäre abzuwägen, ob für diesen Zweck nicht die Injektion via Parameter besser wäre oder ob gerade die Abhängigkeit ein Signal ist, zu einer Instanzmethode zu wechseln. Außerdem in Anschlag zu bringen ist der Zweck von Methode und Klasse. Passt es zur Aufgabe von Aggregator{} bzw. SumUpFileContent(), einen ctor-Parameter zu haben oder fühlt sich ein Funktionsparameter natürlicher an? In jedem Fall sollte die Abhängigkeit von testrelevanten Konstanten einen so begrenzten Einfluss wie möglich haben.

Abhängigkeit von Zustand

Häufiger als die Abhängigkeit von Konstanten verhagelt die Abhängigkeit von Zustand eine gute Testbarkeit.

Funktionen einer Klasse können Daten gemeinsam benutzen oder dieselbe Funktion kann mit sich selbst Daten teilen über Aufrufe hinweg.

Wenn dann die Logik in einer Funktion getestet werden soll, müsste eigentlich zuerst Logik in einer anderen Funktion aufgerufen werden, um den passenden Zustand aufzubauen.

Als Beispiel mag ein FileProvider{} dienen, der die zu summierenden Zahlen beschafft:

internal class FileProvider {
	public int[] Load(string filename) {
            var lines = Load_text(filename);
	    return Parse(lines).ToArray();
	}

        private IEnumerable<string> Load_text(string filename) {
             return System.IO.File.ReadAllLines(filename);
        }

	internal static IEnumerable<int> Parse(IEnumerable<string> lines) {
	    foreach (var line in lines)
                if (!string.IsNullOrWhiteSpace(line))
                     yield return int.Parse(line);
	}
}

Der hat noch keinen Zustand. Doch was, wenn die Datei nicht immer wieder geladen soll? Die Zahlen könnten gecachet werden. Nur, wenn der letzte Aufruf lange genug zurückliegt, würde die Datei erneut gelesen. Die Verfallszeit könnte 1 Minute betragen.

Ein FileProvider{} mit Cache müsste sich merken, mit welcher Datei er aufgerufen wurde, wann dieser Aufruf stattgefunden hat und was das Ergebnis war. Das wäre einiger Zustand, von dem er abhängig wäre. Zusätzlich gäbe es eine Abhängigkeit von einer Konstanten, der Cache-Verfallszeit.

Zustand und Konstante sind Details der Klasse. Doch für Testzwecke wäre es vorteilhaft, sie gezielt setzen zu können. Die Implementation könnte so aussehen:

internal class FileProvider {
    string previousFilename;
    DateTime loadTime;
    int[] numbers;
    TimeSpan expirationSpan;

    public FileProvider() : this(null, 
                                 DateTime.MinValue, 
                                 null, 
                                 new TimeSpan(0,1,0)) { }
    internal FileProvider(string previousFilename, 
                          DateTime loadTime, 
                          int[] numbers, 
                          TimeSpan expirationSpan) {
        this.previousFilename = previousFilename;
        this.loadTime = loadTime;
        this.numbers = numbers;
        this.expirationSpan = expirationSpan;
    }

	public int[] Load(string filename) {
            return Retrieve_from_cache(filename,
                            Acquire_numbers);

            int[] Acquire_numbers() {
                 var lines = Load_text(filename);
                 return Parse(lines).ToArray();
             }
	}

    internal int[] Retrieve_from_cache(string filename, Func<int[]> get_data) {
        if (Cache_hit())
            return this.numbers;
        else
            return Reset_cache();

        bool Cache_hit() {
            return this.previousFilename == filename &&
                       DateTime.Now.Subtract(this.expirationSpan) < this.loadTime;
        }

        int[] Reset_cache() {
            this.previousFilename = filename;
            this.loadTime = DateTime.Now;
            this.numbers = get_data();
            return this.numbers;
        }
    }
…

Sie sehen am Anfang der Klasse den Zustand ihrer Instanzen, der über einen Konstruktor gesetzt werden kann, aber nicht muss.

Für das neue Verhalten ist ein Aufruf der Funktion Retrieve_from_cache() in Load() dazugekommen. Der umfängt die bisherige Datenbeschaffung, die jetzt nur noch bei Bedarf aufgerufen wird.

In Retrieve_from_cache() sehen Sie, dass zwischen einem cache hit und einem cache miss unterschieden wird. Ein hit ist, wenn der Cache noch gültig ist, weil die gerade angefragte Datei dieselbe wie die vorherige ist und der letzte Aufruf noch nicht zu weit in der Vergangenheit liegt. Dann werden die schon geladenen Zahlen zurückgeliefert. Nur bei einem miss fragt das Caching nach neuen Inhalten und setzt den Zustand zurück.

Die Testbarkeit ist durch die Möglichkeit der Injektion des Zustands hoch. Hier drei Gerüsttests, die sich den Zustand vor Aufruf der zu testenden Funktion gezielt einrichten:

[Test]
public void Retrieve_from_cache() {
    var expected = new[] { 1, 2, 3 };
    var sut = new FileProvider("a.txt", DateTime.Now, expected, new TimeSpan(1,0,0));
    var result = sut.Retrieve_from_cache("a.txt", null);
    Assert.AreEqual(expected, result);
}

[Test]
public void Cache_miss_due_to_different_filename() {
    var expected = new[] { 1, 2, 3 };
    var sut = new FileProvider("a.txt", DateTime.Now, null, new TimeSpan(1, 0, 0));
    var result = sut.Retrieve_from_cache("x.txt", () => new[]{1,2,3} );
    Assert.AreEqual(expected, result);
}

[Test]
public void Cache_miss_due_to_expiration() {
    var expected = new[] { 1, 2, 3 };
    var sut = new FileProvider(
                "a.txt", 
                DateTime.Now.Subtract(new TimeSpan(2,0,0)), 
                null, 
                new TimeSpan(1, 0, 0));
    var result = sut.Retrieve_from_cache("a.txt", () => new[] { 1, 2, 3 });
    Assert.AreEqual(expected, result);
}

Ein Akzeptanztest kann die Konfigurierbarkeit des Zustands natürlich auch nutzen:

[Test]
public void Load_from_cache() {
    var expected = new[] { 1, 2, 3 };
    var sut = new FileProvider("numbers.txt", DateTime.Now, expected, new TimeSpan(1, 0, 0));
    var result = sut.Load("numbers.txt");
    Assert.AreEqual(expected, result);
}

Dieses Vorgehen funktioniert gut, solange der Zustand nicht zu umfangreich ist oder nur gesetzt werden soll. Zustandsänderungen hingegen lassen sich nicht überprüfen, zumindest nicht, wenn es sich um primitive Datentypen wie int oder string handelt.

Noch besser ist die Testbarkeit daher, wenn der Zustand in einem eigenen Objekt zusammengefasst wird, z.B.

internal class FileProvider {
    internal class FileProviderState {
        public string previousFilename;
        public DateTime loadTime = DateTime.MinValue;
        public int[] numbers;
        public TimeSpan expirationSpan = new TimeSpan(0,1,0);
    }
    private FileProviderState state;


    public FileProvider() : this(new FileProviderState()) { }
    internal FileProvider(FileProviderState state) {
        this.state = state;
    }
...

Ein Zustandsobjekt kann im Test nicht nur vorbelegt und injiziert, sondern auch nach Aufruf der FUT überprüft werden. Das ist zwar ein Blick hinter die Kulissen auf ein Detail der Implementation. Doch allemal in Gerüsttests ist das ein legitimes Vorgehen, um zielstrebig Korrektheit für eine in dem Moment ohnehin im Detail bekannte Struktur herzustellen.

[Test, Description("Gerüttest")]
public void Load_into_cache() {
    var state = new FileProvider.FileProviderState();
    var sut = new FileProvider(state);

    sut.Load("numbers.txt");

    Assert.AreEqual(state.previousFilename, "numbers.txt");
    Assert.AreEqual(state.numbers, new[]{1, 22, 333} );
    Assert.IsTrue(DateTime.Now.Subtract(new TimeSpan(0,0,1)) < state.loadTime);
}

Abhängigkeit von Ressourcen

Abhängigkeiten reduzieren die Testbarkeit von Logik. Injektion hilft, das zu kompensieren. Davon können Operationen schon für testrelevante Daten wie Konstanten und Zustand profitieren.

Aber auch auch wenn Operationen nicht mehr funktional abhängig sind, enthalten Sie durchaus noch eine letzte Abhängigkeit, die die Testbarkeit erschwert.

Funktionale Abhängigkeit wurde bisher in Bezug auf andere Domänenfunktionalität gesehen. Oder denken Sie stattdessen „Logik, die ich selbst geschrieben habe.“ Eine Funktion, die eine andere Funktion aufruft, die ebenfalls Sie bzw. Ihr Team mit Logik gefüllt haben, ist funktional abhängig. Das sollte nicht sein. Folgen Sie dem IOSP.

Operationen können jedoch nicht operieren, wenn sie nicht irgendwelche Funktionen aufrufen. Jedes +, jedes System.Console.WriteLine(), jedes string.Join() ist ein Funktionsaufruf, der Logik in Bewegung setzt. Allerdings haben nicht Sie diese Logik entwickelt. Sie denken darüber nicht nach; die Logik liegt als Black Box vor. Sie benutzen existierende, für Sie unveränderliche Bibliotheken, Frameworks, Services.

Weil diese Funktionen für Sie Black Boxes sind, werden sie auch der Logik zugeschlagen und nicht als funktionale Abhängigkeiten gewertet. Sie sind unvermeidbar. Aber sie sollten eben in Operationen konzentriert werden.

Die meisten solcher Funktionsaufrufe sind für die Testbarkeit unkritisch. Solange eine Funktion vorliegt, die sie zusammenfasst, ist der Testbarkeit Genüge getan.

Einige dieser Black Boxes machen den Test von Operationen jedoch schwer. Das sind Funktionen, die auf Ressourcen zugreifen. Sie nutzen über in-memory Zustand hinaus in irgendeiner Weise Hardware, das kann eine Festplatte, eine Internetverbindung, der Bildschirm, die Maus, ein Scanner, ein anderer Thread usw. sein.

Solche Ressourcen sind in Tests oft schwer oder gar nicht zu kontrollieren. Das verringert die Testbarkeit von Operationen erheblich, die von Ressourcen abhängen.

Eine Operation, deren ausgewiesene Aufgabe es ist, eine Ressource zu benutzen, kann in dieser Hinsicht nicht testbarer gemacht werden. Aber wie sieht es mit folgender Funktion aus:

public class Aggregator {
    public int SumUpFileContent(string filename) {
        var sum = 0;
        var lines = File.ReadAllLines(filename);
        foreach (var line in lines)
            if (!string.IsNullOrWhiteSpace(line))
                sum += int.Parse(line);
        return sum;
    }
}

Das ist eine Operation. Diese Operation ist von einer Ressource abhängig: der Festplatte oder dem Dateisystem bzw. einer Datei. Auf diese Ressource greift sie mittels eines I/O-API zu, hier: System.IO.File.ReadAllLines().

Ist es aber die „ausgewiesene Aufgabe“ der Operation, auf eine Ressource zuzugreifen? Nein. Sie tut es nur im Rahmen eines größeren Zwecks. Der Ressourcenzugriff ist lediglich ein Aspekt ihrer Verantwortlichkeit.

Ressourenzugriffe sind Verantwortlichkeitssignale. Wo sie stattfinden, sollten Sie genau hinschauen, ob eine Operation auch wirklich nur darauf konzentriert ist. Andere Aspekte sollten mit Ressourcenzugriffen nicht in einer Operation zusammengefasst werden. Zugriffe auf verschiedene Ressourcen in einer Operation sind ebenfalls zu vermeiden.

Um die Testbarkeit der obigen Funktion zu erhöhen, sollte also der Ressourcenzugriff herausgelöst und injiziert werden. Beispiel:

public class Aggregator {
    readonly Func<string, string[]> load_text;

    public Aggregator() : this(Load_text) {}
    internal Aggregator(Func<string, string[]> load_text)
        => this.load_text = load_text;

    public int SumUpFileContent(string filename) {
        var sum = 0;
        var lines = this.load_text(filename);
        foreach (var line in lines)
            if (!string.IsNullOrWhiteSpace(line))
                sum += int.Parse(line);
        return sum;
    }

    internal static string[] Load_text(string filename)
        => File.ReadAllLines(filename);
}

Jetzt ist die Funktion nicht mehr direkt von der Ressource abhängig, sondern von einer „Abstraktion“. Die wird injiziert, allerdings nur als Funktion. Wenn Sie wollen, können Sie natürlich auch eine weitere Klasse für den Ressourcenzugriff definieren und mit einem Interface versehen, um dem üblichen Muster von DIP/IoC zu entsprechen. Doch das ist nur ein Unterschied in der Form, nicht prinzipiell.

Die Entscheidung, ob der Ressourcenzugriff nur in eine Methode der bisherigen Klasse oder gar in eine eigene Klasse ausgelagert werden sollte, ist nicht unwichtig, beeinflusst die Testbarkeit jedoch weniger als überhaupt die Einführung einer Injektion.

In jedem Fall kann ein Test jetzt leicht ohne Ressourcenaufwand durchgeführt werden:

[Test()]
public void Akzeptanztest_mit_Ressourcenattrappe() {
    var sut = new Aggregator( _ => new[]{"1", "2", "3"} );
    var result = sut.SumUpFileContent("unwichtig.txt");
    Assert.AreEqual(6, result);
}

Der Preis für eine Attrappe ist gering im Vergleich zum Gewinn an Testbarkeit.

Aber Vorsicht! Attrappen sind kein Selbstzweck. Strukturieren Sie zuerst Ihren Code nach IOSP. Attrappen sind dann lediglich noch nötig, um hier und da Logik im Test von sehr fokussierten Ressourcenzugriffen zu entkoppeln. Attrappen sind kein Mittel, um dem Grundübel funktionaler Abhängigkeiten zu entgehen.

Ein anderes Beispiel für eine Ressourcenabhängigkeit, die oft übersehen wird, finden Sie oben im Beispiel zur Zustandsinjektion:

[Test, Description("Gerüttest")]
public void Load_into_cache() {
    var state = new FileProvider.FileProviderState();
    var sut = new FileProvider(state);

    sut.Load("numbers.txt");

    Assert.AreEqual(state.previousFilename, "numbers.txt");
    Assert.AreEqual(state.numbers, new[]{1, 22, 333} );
    Assert.IsTrue(DateTime.Now.Subtract(new TimeSpan(0,0,1)) < state.loadTime);
}

Sehen Sie die Abhängigkeit, die den Test erschwert? Es ist die von der Zeit. Die letzte Überprüfung ist umständlich: Eigentlich sollte es reichen zu schreiben Assert.AreEqual(DateTime.Now, state.loadTime), denn der Ladezeitpunkt ist ja der Zeitpunkt, zu dem der Test läuft. Eigentlich. Das stimmt nämlich nur im Groben. Wenn Sie genau hinschauen, ist die Zeit ja weitergelaufen seitdem state.loadTime gesetzt wurde. DateTime.Now im Test würde einen anderen Zeitpunkt liefern und der Test fehlschlagen, auch wenn der Zeitunterschied nur eine Millisekunde beträgt.

Hier kann die Lösung wieder in einer Injektion bestehen. Der „Zeitgeber“ könnte Teil des Zustands sein oder separat injiziert werden:

internal class FileProvider {
    ...
    private FileProviderState state;
    readonly Func<DateTime> currentTime;

    public FileProvider() : this(new FileProviderState(), () => DateTime.Now) { }
    internal FileProvider(FileProviderState state, Func<DateTime> currentTime) {
        this.currentTime = currentTime;
        this.state = state;
    }
    ...

    internal int[] Retrieve_from_cache(string filename, Func<int[]> get_data) {
        if (Cache_hit())
            return this.state.numbers;
        else
            return Reset_cache();

        bool Cache_hit() {
            return this.state.previousFilename == filename &&
                   this.currentTime().Subtract(this.state.expirationSpan) < this.state.loadTime;
        }

        int[] Reset_cache() {
            this.state.previousFilename = filename;
            this.state.loadTime = this.currentTime();
            this.state.numbers = get_data();
            return this.state.numbers;
        }
    }
...

Im Test lässt sich dann die Zeit festgelegen:

[Test, Description("Gerüttest")]
public void Load_into_cache() {
    ...
    var expectedTime = new DateTime(2017, 5, 5, 11, 28, 30);
    var sut = new FileProvider(state, () => expectedTime);

    sut.Load("numbers.txt");

    ...
    Assert.AreEqual(expectedTime, state.loadTime);
}

Zusammenfassung

Hohe Testbarkeit ist gegeben, wenn Logik

  • freigestellt,
  • sichtbar,
  • statisch,
  • unabhängig

ist.

Das sieht wie ein Gewinn für die Korrektheit aus und ist es auch. Aber es ist auch ein enormer Gewinn für die Wandelbarkeit. Der ist die Testbarkeit zugeschlagen, weil sie nicht unmittelbar Korrektheit nachweist, sondern nur potenziell, d.h. in der Zukunft bei Bedarf, wenn sich Logik verändert. Dann soll Logik so strukturiert sein, dass „Testsonden“ ganz gezielt angelegt werden können.

Das ist nur möglich, wenn der Code modular ist. Was zusammengehört, ist in Modulen von Funktion bis Service zusammengefasst (hohe Kohäsion). Was sich nicht beeinflussen und unbekannt sein soll, ist hinter Modulkontrakten verborgen (lose Kopplung). Module, insbesondere Funktionen, mit klar erkennbaren, abgegrenzten und erreichbaren Verantwortlichkeiten sind die Voraussetzung für gezielte Eingriffe zur Verbesserung der Korrektheit (Bug Fix) bzw. Erweiterung des Verhaltens.

Ein Review für die Zukunftsfähigkeit im Sinne der Wandelbarkeit muss auf Modularität achten. Statt jedoch bei Abstraktionen zu beginnen, sollte der Ausgangspunkt ganz konkret die Logik sein.

Wir empfehlen also einen bottom-up Review, der sich fragt, ob Logik so in Funktionen strukturiert ist, dass sie leicht testbar ist. Indem darauf das Augenmerk gelegt wird, entstehen kleine und kleinste Funktionen, die eine Masse bilden, in der Muster erkannt werden können. Das sind dann naheliegende Abstraktionen, die ihren Ausdruck in Modulen höherer Ordnung finden können.

Durch einen Fokus auf die Testbarkeit entstehen Klassen und Bibliotheken quasi nebenbei. Die Grobstruktur einer Codebasis bekommt die Chance zu emergieren.

Weitere Artikel in dieser Serie:

Veröffentlicht in Vorgehen und verschlagwortet mit .