Microsoft Research hat vor kurzer Zeit PEX zum freien Download veröffentlicht. Hinter diesem unscheinbaren Kürzel verbirgt sich ein Tool das absolut genial und beeindruckend ist und klar mach, warum Microsoft ein research center unterhält. Das Tool verspricht nichts weniger als die automatische Generierung von Unit-Tests und den dazugehörigen Testcases um eine möglichst hohe Code-Abdeckung zu erzielen. Detaillierte Informationen bietet das Whitepaper, wer sich auf die schnelle einen Einblick verschaffen möchte findet einen Überblick im Folgenden (na ja, für einen Überblick ist der Post vielleicht doch ein wenig lang geraden, aber ich konnte mich nicht bremsen vor Begeisterung):
Darf ich vorstellen - PEX
Gegeben sei folgende Methode die getestet werden soll:
1: public string SimpleTest(int x1, int x2)
2: { 3: if (x1 > x2)
4: return "x1 > x2";
5: if (x1 < x2)
6: return "x1 < x2";
7: else
8: return "x1 == x2";
9: }
Für das versierte Auge eines Entwicklers ist sofort klar, da brauchen wir 3 Testcases um eine vollständige Code-Abdeckung zu erzielen. Mal sehen, was PEX daraus macht. Zunächst mal muss PEX heruntergeladen und installiert sein. Dann kann man einfach einen "Parameterized Unit Test Stub" erzeugen. Dazu in der Methode rechts klicken und den Befehl aus dem Pex-Menü auswählen.
Im folgenden Dialog können Sie verschiedene Parameter angeben. Das wichtigste hier ist das Testprojekt in dem der Stub erzeugt werden soll.
Der erzeugte Stub sieht dann so aus:
1: /// <summary>
2: /// This class contains parameterized unit tests for Calculation
3: /// </summary>
4: [TestClass]
5: [PexClass(typeof(Calculation))]
6: public partial class CalculationTest
7: { 8: [PexMethod]
9: public string SimpleTest(
10: [PexAssumeUnderTest]Calculation target,
11: int x1,
12: int x2
13: )
14: { 15: string result = target.SimpleTest(x1, x2);
16: return result;
17: // TODO: add assertions to method CalculationTest.SimpleTest(Calculation, Int32, Int32)
18: }
19:
20: }
Bei diesem Stub handelt es such um eine Vorlage für einen parameterisierten Unit-test. Toll, und was lässt sich damit nun machen? Wir können eine "Exploration" starten.
Diese Exploration versucht nun Input-Parameter zu finden die zu einer möglichst 100%igen Code-Abdeckung führen. Und hier beginnt nun die Magic von PEX. Ohne unser Zutun findet PEX 3 Kombinationen von Input-Parametern die tatsächlich eine komplette Code-Abdeckung erzielen- WOW! Dazu analysiert PEX wirklich den von uns erstellten Code und kann daraus definieren, mit welchen Input-Parametern die einzelnen noch nicht abgedeckten Zweige erreicht werden können.
Und das schönste, PEX baut uns automatisch 3 Unit-Tests die diese Testcases implementieren:
1: [TestMethod]
2: [PexGeneratedBy(typeof(CalculationTest))]
3: public void SimpleTest01()
4: { 5: string s;
6: Calculation calculation = new Calculation();
7: s = this.SimpleTest(calculation, 1, 2);
8: Assert.AreEqual<string>("x1 < x2", s); 9: }
10:
11: [TestMethod]
12: [PexGeneratedBy(typeof(CalculationTest))]
13: public void SimpleTest02()
14: { 15: string s;
16: Calculation calculation = new Calculation();
17: s = this.SimpleTest(calculation, 1879212556, 1879212556);
18: Assert.AreEqual<string>("x1 == x2", s); 19: }
20:
21: [TestMethod]
22: [PexGeneratedBy(typeof(CalculationTest))]
23: public void SimpleTest03()
24: { 25: string s;
26: Calculation calculation = new Calculation();
27: s = this.SimpleTest(calculation, 256, 254);
28: Assert.AreEqual<string>("x1 > x2", s); 29: }
Diese Unit-Tests können wir nun starten und sehen, dass diese wie erwartet alle erfolgreich sind.
Damit haben wir einen Test automatisiert erstellt, der sicherstellen kann, dass diese Methode ihr Verhalten nach Außen für die aktuell definierten Test-Cases nicht verändert. Damit können ungewollte Änderungen an der Methode erkannt und beseitigt werden. Ob die Methode allerdings ihre Aufgabe korrekt erledigt, kann PEX natürlich nicht testen. Haben wir die Funktionsweise einer Methode allerdings einmal validiert, kann PEX nun sehr einfach dieses Verhalten prüfen. Und natürlich eignet es sich auch sehr gut um mögliche Test-Cases zu definieren. Es müssen in diesem Fall dann nur noch die einzelnen Ergebnisse je Test validiert werden.
Ändern wir die Methode ab, so dass sich ihr Verhalten ändert, dann alarmiert uns der entsprechende Test.
Soweit sogut - Und was geht sonst noch?
Wenn wir nun ein gewünschte Änderung der Funktionalität implementieren, wie kann PEX dann damit umgehen? Zunächst würden wir die vorhandenen Tests durchführen, damit wir sicher sind, dass die aktuelle Funktionalität noch korrekt läuft. Dann erweitern wir unsere Methode:
1: public string SimpleTest(int x1, int x2)
2: { 3: if (x1 > x2 * 2)
4: return "x1 > x2 * 2";
5: if (x1 > x2)
6: return "x1 > x2";
7: if (x1 < x2)
8: return "x1 < x2";
9: else
10: return "x1 == x2";
11: }
Zeile 3+4 haben wir neu hinzugefügt. Nun starten wir eine neue Exploration und PEX ermittelt einen weiteren Test-Case um diese Funktion ebenfalls abzudecken.
Schön - darf's noch ein bisschen mehr sein?
Dieses einfache Sample war ja schon sehr beeindruckend. Die Frage, die sich aber natürlich direkt stellt, ist wie weit geht denn das? Wir wollen nun den Schwierigkeitsgrad für PEX schrittweise steigern. Integer-Werte sind ja noch relativ einfach zu handhaben, aber wie sieht's denn beispielsweise mit Strings aus? Hierzu zunächst wieder eine Methode, die wir testen wollen:
1: public class StringOperations
2: { 3: public string CheckString(string Input)
4: { 5: if (Input.StartsWith("abc") && Input.Length > 10) 6: return Input + " Starts with 'abc' and length > 10";
7: if (Input.StartsWith("abc")) 8: return Input + " Starts with 'abc'";
9: if (Input.StartsWith("ABC")) 10: return Input + " Starts with 'ABC'";
11: return "Unknown pattern";
12: }
13: }
Stubs erzeugen und Exploration starten. Ob PEX wohl solche Operationen wie "StartsWith" und "Length" versteht?
Es findet tatsächlich alle erforderlichen Input-Parameter und sogar noch mehr! PEX stell fest, dass unsere Methode beim Übergeben einer NULL-Referenz eine Exception wirft. Und damit nicht genug, PEX kann uns auch einen Vorschlag machen, wie wir unseren Code verbessern können. Dazu im "Pex Exploration Results" - Fenster unter Views "Show suggestions window" aufrufen.
Durch einen Doppelklick auf den Eintrag am unteren Rand des Bereichs öffnet sich ein Fenster, das die vorgeschlagene Änderung direkt in unseren Code einfügen kann.
1: public string CheckString(string Input)
2: { 3: // <pex>
4: if (Input == (string)null)
5: throw new ArgumentNullException("Input"); 6: // </pex>
7: if (Input.StartsWith("abc") && Input.Length > 10) 8: return Input + " Starts with 'abc' and length > 10";
9: if (Input.StartsWith("abc")) 10: return Input + " Starts with 'abc'";
11: if (Input.StartsWith("ABC")) 12: return Input + " Starts with 'ABC'";
13: return "Unknown pattern";
14: }
Die Zeilen 3-6 wurden von PEX erzeugt. Natürlich können wir das entsprechende Verhalten im Code direkt ändern und an unsere Vorstellungen anpassen. Vielleicht ist es aber gar keine schlechte Idee, in diesem Fall eine Exception zu werden. Dies ist das Standard-Verhalten von PEX an dieser Stelle. Wird die erwartete Exception nicht mehr geworfen oder eine andere Exception tritt auf, wird dies durch einen fehlgeschlagenen Test angezeigt.
Der nächste bitte!
So nun wollen wir noch einen Schritt weitergehen und sehen, wie PEX mit Listen umgehen kann. Dazu habe ich folgende Testmethode erstellt (über den Sinn einer solchen Methode wollen wir jetzt nicht nachdenken)
1: int result = 0;
2: if (list.Count > 10)
3: { 4: foreach (int i in list)
5: result += i;
6: }
7: else
8: { 9: foreach (int i in list)
10: result *= i;
11: }
12: return result;
Die Methode bekommt eine Liste von Integer-Werten übergeben. Wenn es mehr als 10 Elemente sind, werden diese addiert, sonst werden die Werte miteinander Multipliziert. Mal sehen, wie PEX mit Listen umgeht.
PEX erkennt noch, dass unsere Methode mit Null-References nicht korrekt umgeht, aber dann verließen sie ihn. Aber freundlicherweise bekommen wir noch einen Hinweis "2 Object Creations". klickt man darauf, dann bekommt man schon mehr Informationen.
Aha, PEX kann also eine List<int> nicht erzeugen. Also was tun? Klickt man den unteren der beiden Einträge an, bietet PEX etwas weiter rechts die Möglichkeit eine Factory zu definieren. Eine Factory ist ein Extensibility-Point mit dem PEX beigebracht werden kann mit solchen Objekten umzugehen. Ein Beispiel für eine solche Factory kann so aussehen:
1: namespace System.Collections.Generic
2: { 3: [PexFactoryClass]
4: public partial class ListFactory
5: { 6: [PexFactoryMethod(typeof(List<int>))]
7: public static List<int> Create(int i)
8: { 9: if (i > 100)
10: i = 100;
11: List<int> l = new List<int>();
12: for (int j = 0; j < i; j++)
13: { 14: l.Add(j * 10);
15: }
16: return l;
17: }
18: }
19: }
Hier teilt man PEX nun mit, welche Elemente es damit erzeugen kann (Zeile 6). Anschließend implementiert man eine Create-Methode die beliebige Parameter übernehmen kann. In Abhängigkeit dieser Parameter wird nun eine Instanz des gewünschten Objektes erzeugt. In unserem Beispiel übernehmen wir nur einen Parameter der die Länge der Liste angibt. In den Zeilen 9/10 begrenzen wir die Länge der Lsite auf 100 Elemente. Die Liste selbst befüllen wir mit einer Reihe von Zahlen. Hier ist es sicher keine gute Idee, z.B. Zufallszahlen zu verwenden, da diese ja bei jedem Testdurchlauf andere Werte liefern und deshalb der Assert nicht erfolgreich ausgeführt werden kann.
Mit hilfe dieser Factory kannPEX nun unsere Testcases definieren. Dazu ermittelt es einfach geeignete Parameter für die Create-Methode in unserer Factory statt das Objekt selbst zu erzeugen.
Die erzeugten Tests sehen dann so aus:
1: [TestMethod]
2: [PexGeneratedBy(typeof(ListCalculationTest))]
3: public void SumList03()
4: { 5: List<int> list;
6: int i;
7: list = ListFactory.Create(2);
8: ListCalculation listCalculation = new ListCalculation();
9: i = this.SumList(listCalculation, list);
10: Assert.AreEqual<int>(0, i);
11: }
12:
13: [TestMethod]
14: [PexGeneratedBy(typeof(ListCalculationTest))]
15: public void SumList04()
16: { 17: List<int> list;
18: int i;
19: list = ListFactory.Create(536870912);
20: ListCalculation listCalculation = new ListCalculation();
21: i = this.SumList(listCalculation, list);