Zum Handwerkszeug bei der Entwicklung von Software gehört es, (sinnvolle) automatisierte Tests zu schreiben. Mittels Unit-, Integration- und End-to-end-Tests wird die korrekte Funktionalität unseres Codes sichergestellt und wir erhalten die Sicherheit, dass wird beim Umsetzen neuer Anforderungen und beim Refactoring bestehenden Codes keine vorhandene Funktionalität kaputt machen. Michael Feathers definiert Legacy Software sogar als Code, der nicht getestet ist.

legacy code is code without tests

Michael Feathers

Neben den verbreiteten Ansätzen von Example-based testing oder Snapshot testing gibt es noch weitere Möglichkeiten, die uns helfen, die Korrektheit unseres Codes zu überprüfen. Einer dieser Ansätze ist Property-based testing.

Property-based testing prüft nicht einzelne Werte auf Übereinstimmung, sondern zielt auf die Verifikation von Eigenschaften ab, die unsere Implementierung aufweisen müssen. So ist für die mathematische Addition die Kommutativität eine Eigenschaft, die wir überprüfen können, ohne uns konkrete Zahlen anzuschauen. Für die Addition zweier Zahlen a und b gilt immer:

a + b = b + a

Property-based testing

Property testing oder Property-based testing (PBT) ist ein Testverfahren, dass sich in der funktionalen Programmierung etabliert hat, etwa durch das Haskell-Package QuickCheck. Prädestiniert ist es für Problemstellungen, die wesentlich einfacher zu verifizieren als zu lösen sind. Aber auch beim Testen von alltäglicheren Problemen kann es gute Dienste leisten.

Beim Property-based testing definiert man Eigenschaften, die der zu testende Code für eine Menge von Eingabedaten erfüllen muss. Eine solche Eigenschaft ist dabei nicht wie eine Property, eine Klassen-Eigenschaft, in C# zu verstehen.

Die Eigenschaft Kommutativität der mathematischen Addition lässt sich in C# als Funktion beschreiben.

Func<int, int, bool> commutativity = (int a, int b) => a + b == b + a;

Die Definition der Addition besagt, dass das Kommutativgesetz für sämtliche Kombinationen von a und b gilt, unsere Funktion commutativity also für alle Integer-Werte true zurück liefern muss.

Wenn wir unsere eigene Addition-Funktion implementieren, können wir diese Eigenschaft nutzen, um die Korrektheit unserer Implementierung zu überprüfen.

Func<int, int, bool> commutativity = (int a, int b) 
    => MathOperations.Add(a, b) == MathOperations.Add(b, a);

Example-based testing

Ein naiver Ansatz, die Korrektheit unserer Addition-Funktion zu testen, ist es, die Funktion mit ein paar Beispiel-Werten aufzurufen und die Rückgabewerte zu überprüfen, hier mittels NUnit.

[TestCase(0, 0)]
[TestCase(1, 2)]
[TestCase(999, 9999)]
public void TestCommutativity_WithExamples(int a, int b)
{
    var left = MathOperations.Add(a, b);
    var right = MathOperations.Add(b, a);

    Assert.AreEqual(left, right);
}

Da uns die konkreten Werte von a und b aber gar nicht interessieren, können wir sie auch zufällig generieren lassen. Dies können wir für beliebig viele Kombinationen aus a und b machen, hier sind es 1.000 Durchläufe. Damit haben wir statt der drei konkreten Werte nun 1.000 zufällig generierte Werte getestet und können uns bereits ein Stück sicherer sein, dass unsere Addition-Funktion korrekt arbeitet.

[Test]
public void TestCommutativity_WithRandomValues()
{
    for (var i = 0; i < 1_000; i++)
    {
        var a = Random.Shared.Next(-999, 999);
        var b = Random.Shared.Next(-123, 321);

        var left = MathOperations.Add(a, b);
        var right = MathOperations.Add(b, a);

        Assert.AreEqual(left, right);
    }
}

Allerdings bringt dieses Vorgehen auch ein paar Nachteile mit sich: Offensichtlich ist, dass es mehr Code bedarf und unser Testfall unübersichtlicher wird. Zudem sind die generierten Werte willkürlich gewählt und decken vielleicht nicht den gesamten Wertebereich ab. Ein größeres Problem ist aber, dass wir bei einem Fehler nicht wissen, mit welchen Eingabewerten der Fehler aufgetreten ist, da sie bei jedem Durchlauf zufällig generiert werden.

FsCheck

Die in F# geschriebene Bibliothek FsCheck schafft hier Abhilfe und lässt sich auch in C# verwenden. Die gute Dokumentation verwendet zumeist F#-Syntax aber zeigt auch Beispiele in C#.

Sie bietet Funktionen, um uns das Testen von Eigenschaften zu erleichtern. Ein Test in FsCheck besteht im Grunde aus folgenden drei Teilen:

> for all (x, y, ..)
> such as precondition(x, y, ...) holds 
> property(x, y, ...) is satisfied

So können wir die Eigenschaft Kommutativität mit FsCheck wie folgt testen.

[Test]
public void TestCommutativity()
{
    var property = (int x, int y)
        => MathOperations.Add(x, y) == MathOperations.Add(y, x);

    Prop.ForAll(
            Arb.From<int>(), // Parameter x
            Arb.From<int>(), // Parameter y
            property)
        .QuickCheckThrowOnFailure();
}

Noch einfacher wird es, wenn wir uns der Erweiterung FsCheck.NUnit bedienen (für XUnit existiert äquivalent FsCheck.Xunit). Anstatt [Test] bzw. [Fact] annotieren wir unsere Testmethode mit [Property] und können die Eigenschaften direkt als Parameter übergeben. FsCheck generiert automatisch Eingabewerte des entsprechenden Typs.

[Property]
public bool TestCommutativity(int x, int y)
{
    return MathOperations.Add(x, y) == MathOperations.Add(y, x);
}

Möchten wir etwas über die Verteilung der generierten Eingabewerte erfahren, können wir sie nach bestimmten Bedingungen klassifizieren. Durch den Aufruf von Classify() erhalten wir einen Wert vom Typ Property, so dass wir den Rückgabewert der Testmethode entsprechend anpassen müssen.

[Property]
public Property TestCommutativity_WithClassification(int x, int y)
{
    var property = MathOperations.Add(x, y) == MathOperations.Add(y, x);

    return property
        .Classify(x == 0, "x == 0")
        .Classify(x > 0, "x > 0")
        .Classify(x < 0, "x < 0");
}

Die entsprechende Ausgabe liefert in etwa folgendes Ergebnis:

Ok, passed 100 tests.
50% x > 0.
46% x < 0.
4% x == 0.

Möchte man zu jedem einzelnen Testcase eine Ausgabe haben, hilft die Collect()-Methode.

[Property]
public Property TestCommutativity_WithCollect(int x, int y)
{
    var property = MathOperations.Add(x, y) == MathOperations.Add(y, x);

    return property
        .Collect($"x: {x}, y: {y}");
}

Die Ausgabe liefert für jede Kombination eine Zeile inkl. Verteilung.

Ok, passed 100 tests.
2% "x: 5, y: 9".
2% "x: 0, y: 0".
2% "x: -16, y: -6".
1% "x: 9, y: 32".
1% "x: 9, y: -26".
...

Bedingungen

Kommen für einen bestimmten Testfall nicht alle Werte des Datentyps eines Eingangsparameters in Frage, können wir mittels When() eine Vorbedingung definieren. Um bei der Prüfung der Division nicht durch 0 zu teilen, schließen wir diesen Fall aus.

[Property]
public Property TestDivide(int x, int y)
{
    var property = () => MathOperations.Divide(x * y, y) == x;
    return property.When(y != 0);
}

Wichtig hierbei: Die Definition der property ist als Methode ausgeführt, um eine lazy evaluation durch FsCheck zu ermöglichen.

Generator, Shrinker, Arbitrary

Um passende Testdaten zu generieren, nutzt FsCheck Generators, Shrinker and Arbitraries. Die Klasse Gen bietet mit den drei Funktionen Choose(), Constant() und OneOf() die Basis für die Generierung von Werten. Über die Methoden Select() und Where() können die Werte transformiert und gefiltert werden.

// Generiert eine Liste von Zahlen zwischen 0 und 100, die durch 2 teilbar sind
var generator = Gen.Choose(0, 100)
    .Where(i => i % 2 == 0);

// Wählt zufällig einen der beiden Werte "ja" oder "nein" aus
var generator = Gen.OneOf(
        Gen.Constant(true),
        Gen.Constant(false))
    .Select(b => b ? "ja" : "nein");

Über die Methode Sample() liefert ein Generator eine Liste von konkreten Werten. Der erste Parameter size wirkt sich je nach Generator unterschiedliche aus, z.B. kann er den Wertebereich bestimmen. Im Fall von Gen.Choose() hat er keinen Effekt. Der zweite Parameter numberOfSamples legt die Anzahl der Elemente in der generierten Liste fest.

var sample = Gen.Choose(0, 100).Sample(0, 10);
// 22, 6, 16, 8, 27, 22, 14, 49, 42, 99

Shrinker dienen dazu, um Fehler einfacher zu finden. Sie versuchen für einen fehlgeschlagenen Testlauf den Wert zu finden, ab welchem die zu testende Property nicht mehr gilt. Der folgende Test schlägt für jeden Wert >= 20 fehl, mittels Shrinking kann FsCheck den ersten fehlschlagenden Wert 20 ausmachen.

[Property]
public bool Test_Shrink(PositiveInt value)
{
    return value.Item < 20;
}

Ausgabe:

Falsifiable, after 25 tests (2 shrinks) (StdGen (195734675,297194399)):
Original:
PositiveInt 24
Shrunk:
PositiveInt 20

Arbitraries kombinieren Generators und Shrinker und dienen als Eingabe für Property-Tests. Die Klasse Arb definiert eine Reihe von Standardimplementierungen für unterschiedliche Datentypen. Auch Generators für komplexe Typen lassen sich über sie erzeugen.

var arbitrary1 = Arb.Default.PositiveInt();
var arbitrary2 = Arb.Default.DateTime();
var arbitrary3 = Arb.Default.IPv4Address();

var arbitrary4 = Arb.From<int>()
    .Filter(i => i % 2 == 0);

// --- 

Gen<Point> generator = Arb.Generate<Point>();

// --- 

Gen<Point> generator = from x in Arb.Default.PositiveInt().Generator
                       from y in Arb.Default.NegativeInt().Generator
                       select new Point(x.Item, y.Item);

Über Eigenschaften des Property-Attributs lässt sich die Verteilung der Werte steuern.

[Property(StartSize = 0, EndSize = 10)]
public Property Test_Distribution(int value)
{
    var property = () => true;

    return property.Collect($"value: {value}");
}

Die Werte in der Mitte des Wertebereichs (hier 0, weil positive wie negative Zahlen generiert werden) werden häufiger generiert als die Werte am Rand. Für welche konkreten Werte StartSize und EndSize stehen, hängt vom Generator ab.

Ok, passed 100 tests.
18% "value: 0".
13% "value: 1".
9% "value: 2".
8% "value: -2".
7% "value: -4".
7% "value: -1".
5% "value: 5".
5% "value: 3".
5% "value: -8".
5% "value: -6".
4% "value: 4".
3% "value: 7".
2% "value: 9".
2% "value: 8".
2% "value: -7".
1% "value: 6".
1% "value: -9".
1% "value: -5".
1% "value: -3".
1% "value: -10".

Arguments exhausted after X tests

FsCheck führt standardmäßig 100 Testdurchläufe aus. Über die Eigenschaft MaxTest lässt sich die Anzahl der Durchläufe steuern. Schränkt man die validen Optionen zu sehr ein, kann es passieren, dass FsCheck keine Werte mehr generieren kann und eine Exception geworfen. Hierdurch wird vermieden, dass die Laufzeit für einzelnen Tests zu stark ansteigt.

[Property(MaxTest = 100)]
public Property Test_Exhausted(int value)
{
    var property = () => true;

    return property
        .When(value % 15 == 0);
}

Der Test schlägt mit einer Meldung fehl:

Exhausted: Arguments exhausted after 61 tests.

Nun hat man die Möglichkeit, die Anzahl der Testdurchläufe zu reduzieren. Allerdings sollte man sich auch überlegen, ob der Bedingung den gültigen Wertebereich nicht zu sehr einschränken muss. Im Beispiel ist ein int-Wert kein gut gewählter Datentyp.

Beispiel Addition

Um das bereits genannten Beispiel der Addition zu vervollständigen, testen wir nun noch die weiteren Eigenschaften Assoziativität, Identität und Inverse.

[Property]
public bool TestCommutativity(int x, int y)
{
    // x + y == y + x
    return MathOperations.Add(x, y) == MathOperations.Add(y, x);
}

[Property]
public bool TestAssociativity(int x, int y, int z)
{
    // x + (y + z) == (x + y) + z
    return MathOperations.Add(x, MathOperations.Add(y, z)) ==
           MathOperations.Add(MathOperations.Add(x, y), z);
}

[Property]
public bool TestAdditiveIdentity(int x)
{
    // x + 0 == x
    return MathOperations.Add(x, 0) == x;
}

[Property]
public bool TestAdditiveInverse(int x)
{
    // x + (-x) == 0
    return MathOperations.Add(x, -x) == 0;
}

FizzBuzz Kata

Bei dem bekannten FizzBuzz-Kata geht es darum, für eine positive Zahl einen der folgenden Strings zu erzeugen:

  • “Fizz”, wenn die Zahl durch 3 teilbar ist
  • “Buzz”, wenn die Zahl durch 5 teilbar ist
  • “FizzBuzz”, wenn die Zahl durch 3 und 5 teilbar ist
  • die Zahl selbst, sonst

Ein Property-based Test zur Überprüfung einer möglichen Lösung könnte wie folgt aussehen:

[Property]
public Property Should_Get_Fizz(PositiveInt n)
{
    var property = () => FizzBuzzer.GetFizzBuzz(n.Item).Equals("Fizz");

    return property.When(n.Item % 3 == 0 && n.Item % 5 != 0);
}

[Property]
public Property Should_Get_Buzz(PositiveInt n)
{
    var property = () => FizzBuzzer.GetFizzBuzz(n.Item).Equals("Buzz");

    return property.When(n.Item % 3 != 0 && n.Item % 5 == 0);
}

[Property(MaxTest = 10)]
public Property Should_Get_FizzBuzz(PositiveInt n)
{
    var property = () => FizzBuzzer.GetFizzBuzz(n.Item).Equals("FizzBuzz");

    return property.When(n.Item % 3 == 0 && n.Item % 5 == 0);
}

[Property]
public Property Should_Get_Number(PositiveInt n)
{
    var property = () => FizzBuzzer.GetFizzBuzz(n.Item).Equals(n.Item.ToString());

    return property.When(n.Item % 3 != 0 && n.Item % 5 != 0);
}

Ein kleines Beispielprojekt mit dem hier gezeigten Quellcode findet sich auf GitHub.