Vor etwas über einem Jahr habe ich mir einen Ruck gegeben und mein erstes Projekt vollständig test-getrieben entwickelt. Nach etwa 6 Wochen Projektlaufzeit hatte ich rund 400 Unit-Tests geschrieben und eine Anwendung ausgeliefert, die so gut wie fehlerfrei war.
Ich habe mir einiges an Material zu TDD reingezogen, das Video von Gabriel Schenker war wohl am hilfreichsten für den Start (kann ich jedem empfehlen). Trotzdem verlief der Start recht holprig, was in meiner Erinnerung vor allem an Rhino.Mocks lag - das ist ein mächtiges - und obligatorisches - Werkzeug, das man aber erstmal kennenlernen muss. Und da ich kein RTFM-Typ bin, kosteten die ersten Gehversuche eben viel Zeit. Schlussendlich hat es sich aber gelohnt, mit dem Ergebnis konnte ich sehr zufrieden sein.
Nun ist einiges an Zeit vergangen und vor einigen Wochen stand das nächste große Projekt an. Da ich beim ersten TDD-Projekt natürlich im Lauf des Jahres noch Änderungen durchführen musste, hatte ich ein Problem: so, wie ich die Tests damals gestaltet habe, mochte ich das nicht wiederholen. Der Grund wird schnell sichtbar:
Nach meinem heutigen Empfinden sind diese Tests einfach schlecht lesbar. Auch, wenn ich mir damals schon bewusst Mühe gegeben habe sie vernünftig zu benennen. Aber das Naming der Testmethoden zusammen mit der Ausgabe von MS-Test in Visual Studio ist bei mehr als 400 Tests einfach nicht mehr wirklich übersichtlich.
Also habe ich mich nach Alternativen umgesehen, Stichwort BDD, also Behavior Driven Development. Unterm Strich handelt es sich bei BDD auch um TDD, nur mit einem ganzheitlicheren Ansatz. Es gibt einige unterstützende Frameworks für .NET, die mehr oder minder verbreitet und bliebt sind. Zu nennen wären hier z.B. die xUnit Extensions von Björn Rochel (Beispiel) oder mspec.
Unterm Strich konnte ich mich aber mit keinem der von mir evaluierten Frameworks anfreunden. Mir gefällt zwar grundsätzlich, wie die Tests aufgebaut werden und auch die Vorgehensweise, aber eins störte mich doch: es erfordert immer einen gewissen Einarbeitungsaufwand, um die Tests bzw. deren Organisation lesen und verstehen zu können. Es ist einfach für einen BDD-Laien nicht sofort deutlich, wie das Ganze funktioniert, ja nicht intuitiv genug (das mag man anders sehen, wenn man einmal drin ist, aber ich musste irgendwann eine Entscheidung treffen und beginnen, produktiv zu werden ;-)).
Also habe ich für mich persönlich erstmal entschieden auf ein zusätzliches Framework zu verzichten und einen Kompromiss gewählt, denn das Problem der schlecht lesbaren Tests bleibt ja.
Der richtige Testrunner
Den Testrunner von Visual Studio kann man zwar auch etwas anpassen, z.B. mit Gruppiermöglichkeiten, aber nicht so gut wie beispielsweise den von ReSharper. Nun kann ReSharper auch mit MSTest umgehen, aber leider laufen die Tests auch auf meinem Rechner (Core i7, SSD, 12 GB RAM) gähnend langsam.
Also habe ich mich umgesehen und letztendlich für nunit als Test-Framework entschieden (eine Gegenüberstellung der Features der verschiedenen Frameworks findet man übrigens, ausgerechnet, beim Konkurrenten xUnit ;-)).
Ich nutze also aktuell ReSharper + nunit statt MSTest.
Naming und Organisation der Tests
Beim Blick hinüber zu BDD ist dann doch etwas hängen geblieben: eine Vorstellung davon, wie sich Tests lesbarer gestalten lassen. Ich verwende nun einfach folgendes Schema:
That's it. Beispiel:
- Wenn das Registrierungsformular abgeschickt wird
- Muss das wiederholte Passwort mit dem Passwort übereinstimmen
- Müssen die AGB akzeptiert worden sein
In Code:
1: [TestFixture]
2: [Category("Blubr.Web.Controllers.SignUpController")]
3: public class Wenn_das_Registrierungsformular_abgeschickt_wird : ConcernOf<SignUpController>
4: {
5:
6: public override void Setup()
7: {
8: Sut = new SignUpController();
9: }
10:
11: [Test]
12: public void Muss_das_wiederholte_Passwort_mit_dem_Passwort_übereinstimmen()
13: {
14: Sut.Index(new SignUp { UserPassword = "1", UserPasswordConfirmation = "2" });
15: Assert.IsTrue(Sut.ModelState.ContainsKey("UserPasswordConfirmation"));
16: }
17:
18: [Test]
19: public void Müssen_die_AGB_akzeptiert_worden_sein()
20: {
21: Sut.Index(new SignUp { AgreesWithTerms = false });
22: Assert.IsTrue(Sut.ModelState.ContainsKey("AgreesWithTerms"));
23: }
24:
25: }
Auch die Ausgabe im Testrunner ist nun wesentlich lesbarer und sollte zukünftige Fehler, bzw. gebrochene Tests, leichter erkennbar und den Code somit auch wartbarer machen:
Die Gruppierung erfolgt übrigens über das Category-Attribut von nunit, was es ermöglicht unterschiedliche "Test-Cases" für ein Modul, also in diesem Fall z.B. den MVC-Controller "SignUp" in einer weiteren Ebene zusammenzufassen.
Helferlein
Von den BDD-Frameworks übrig geblieben ist die Idee einer abstrakten Basisklasse, die immer wiederkehrende Aufgaben übernimmt bzw. die Infrastruktur dafür bereitstellt. Dazu gehört z.B. dass es in jedem Test ein SUT-Objekt gibt, ein "System under Test". Außerdem nutze ich fast überall Rhino.Mocks zum Mocken von Objekten:
1: using NUnit.Framework;
2: using Rhino.Mocks;
3:
4: namespace Blubr.Specifications.Infrastructure
5: {
6: public abstract class ConcernOf<T>
7: {
8:
9: protected ConcernOf()
10: {
11: MockRepository = new MockRepository();
12: }
13:
14: public T Sut { get; set; }
15: public MockRepository MockRepository { get; private set; }
16:
17: [SetUp]
18: public abstract void Setup();
19:
20: }
21:
22: }
Was sich zudem als sehr hilfreich herausgestellt hat, ist diese Erweiterung von Rhino.Mocks, gepostet von Albert. Damit lassen sich Parameter einfacher faken. Beispiel: ich habe einen NotificationService mit einer Methode Send():
1: public interface INotificationService
2: {
3: void Send(string recipient, string subject,
4: NotificationTemplate template, Dictionary<string, string> parameters);
5: }
Es wäre hier unglaublich aufwendig, bei jedem Test alle Parameter sinnvoll zu setzen, noch dazu, wenn diese für den Test selbst keinerlei Rolle spielen. Also kann man sich mit dieser Erweiterung einfach auf den wirklich relevanten Parameter konzentrieren, z.B. wenn man wissen möchte, ob die Mail auch an die gewünschte Adresse verschickt wurde:
1: var recipient = "info@xyz.de";
2: service.AssertWasCalled(
3: s =>
4: s.Send(Arg<string>.Is.Equal(recipient),
5: Arg<string>.Is.Anything,
6: Arg<NotificationTemplate>.Is.Anything,
7: Arg<Dictionary<string, string>>.Is.Anything));
Aufrufe dieser Art lassen sich, da es ja immer noch viele Parameter bleiben, die gesetzt werden möchten, auch in ExtensionMethods auslagern. Um beim Beispiel zu bleiben, ich möchte immer wieder wissen, ob eine Benachrichtigung mit einem bestimmten Template, an einen bestimmten Adressaten oder mit einem bestimmten Parameter (ein zu ersetzender Platzhalter im Mailtemplate) gesendet wurde. Das kann man leicht auslagern, ohne dass es dabei zu kompliziert würde:
1: public static class NotificationServiceAssertions
2: {
3:
4: public static void AssertSendWasCalledWithTemplate(
5: this INotificationService service, NotificationTemplate template)
6: {
7: service.AssertWasCalled(
8: s =>
9: s.Send(Arg<string>.Is.Anything,
10: Arg<string>.Is.Anything,
11: Arg<NotificationTemplate>.Is.Equal(template),
12: Arg<Dictionary<string, string>>.Is.Anything));
13: }
14:
15: public static void AssertSendWasCalledWithRecipient(
16: this INotificationService service, string recipient)
17: {
18: service.AssertWasCalled(
19: s =>
20: s.Send(Arg<string>.Is.Equal(recipient),
21: Arg<string>.Is.Anything,
22: Arg<NotificationTemplate>.Is.Anything,
23: Arg<Dictionary<string, string>>.Is.Anything));
24: }
25:
26: public static void AssertSendWasCalledWithParameter(
27: this INotificationService service, string key, string value)
28: {
29: service.AssertWasCalled(
30: s =>
31: s.Send(Arg<string>.Is.Anything,
32: Arg<string>.Is.Anything,
33: Arg<NotificationTemplate>.Is.Anything,
34: Arg<Dictionary<string, string>>.List.IsIn(
35: new KeyValuePair<string, string>(key, value))));
36: }
37:
38: }
Beispiele für die Verwendung:
1: [TestFixture]
2: [Category("Blubr.Domain.Services.ClientService")]
3: public class Wenn_ein_valider_Client_angelegt_wurde_wird_eine_Benachrichtigung_gesendet_die :
4: ConcernOfClientService
5: {
6:
7: [Test]
8: public void Das_Template_ClientRegistered_besitzt()
9: {
10: Sut.CreateClient(ValidClient(), new User(), new MockValidationState());
11: NotificationService.
12: AssertSendWasCalledWithTemplate(NotificationTemplate.ClientRegistered);
13: }
14:
15: [Test]
16: public void An_die_Emailadresse_des_Users_geschickt_wird()
17: {
18: Sut.CreateClient(ValidClient(), new User { Email = "user@client.com" },
19: new MockValidationState());
20: NotificationService.AssertSendWasCalledWithRecipient("user@client.com");
21: }
22:
23: [Test]
24: public void Als_Parameter_die_Anrede_enthält()
25: {
26: Sut.CreateClient(ValidClient(),
27: new User { Salutation = Salutation.Mr, LastName = "Bandt" }, new MockValidationState());
28: NotificationService.AssertSendWasCalledWithParameter("Salutation", "Dear Mr. Bandt");
29: }
30:
31: [Test]
32: public void Als_Parameter_den_ActivationKey_enthält()
33: {
34: Sut.CreateClient(ValidClient(), new User(), new MockValidationState());
35: NotificationService.AssertSendWasCalledWithParameter("ActivationKey", "uniquekey");
36: }
37:
38: }
HTH ;-)