Thomas Bandt

Über mich | Kontakt | Archiv

TDD, BDD - Status Quo

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:

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 ;-)

Kommentare

  1. Alex schrieb am Montag, 29. März 2010 15:45:00 Uhr:

    Umlaute im Code... aber sonst gut geschrieben - fleißig gelernt ;-)
  2. Thomas schrieb am Montag, 29. März 2010 15:47:00 Uhr:

    Umlaute sind ganz bewusst drin (sind ja "nur" Tests). Wenn ich könnte, würde ich sogar die _ noch rauslassen und Leerzeichen verwenden ;-).
  3. Alex schrieb am Montag, 29. März 2010 16:05:00 Uhr:

    Ist ja gängige Praxis, die "_" nachträglich aus dem XML zu entfernen, damit der Kunde keine Geschwüre auf den Augen bekommt ;-)
  4. Christina schrieb am Sonntag, 11. April 2010 11:28:00 Uhr:

    Hi Thomas,

    super Artikel, hat mir viele Ideen gegeben, vor allem für das generische ConcernOf. Wir haben auch eine Basisklasse bei den Tests aber deine ist viel besser Lesbar.
    Danke für die Tipps - schon wieder :-D

    Christina
  5. Thomas goes .NET schrieb am Dienstag, 27. April 2010 00:22:00 Uhr:

    Wie ich aktuell test-getrieben entwickle habe ich kürzlich
  6. Thomas goes .NET schrieb am Freitag, 7. Mai 2010 12:16:00 Uhr:

    Vor etwas über einem Monat habe ich meinen ganz eigenen aktuellen "test driven way" vorgestellt. Inzwischen ist die im Screenshot des Testrunners dargestellte Zahl der Tests auf an die 300 gewachsen. Eine nette Zahl, doch ich bin mir sicher bei Absch ...
  7. Thomas goes .NET schrieb am Freitag, 14. Mai 2010 14:45:00 Uhr:

    Als ich vorhin am Artikel über das Mocken des HttpContext schrieb, wollte ich eigentlich eine Demo-Anwendung dazu packen. Als ich mit ihr fertig war, fiel mir auf, dass ich damit leicht über das Ziel hinaus geschossen war. Denn in der App ist viel me ...


« Zurück  |  Weiter »