Det er endelig lykkedes mig at finde et lille projekt, som jeg kan køre strengt efter TDD metodikken, med andre ord testen skrives først. I den forbindelse er der noget mere fokus på access modifiers end når vægten er på integrationstests, men man må jo hænge i og gøre plads til injektere fake objekter efter bogen.

Mens jeg er godt i gang med implementering af validering i et business objekt støder jeg så ind i en "hvorfor-nu-det" situation, som stadig ikke er helt klar for mig. Vi har følgende setup eksempliceret ved det klassiske books eksempel.

I klassen "BookBusinessObject" ønsker vi at teste om en given bog (CurrentBook) findes i en liste af bøger (ListOfBooks). Listen kan fx initialiseres via opslag i en database men for at lette presset lidt på databasen gemmes listen i en statisk liste (books). Den unge dansker skriver sin unit test og får en fin grøn lampe fra NUnit med følgende kode;

    1 using System.Collections.Generic;

    2 using NUnit.Framework;

    3 using Rhino.Mocks;

    4 

    5 public class Book

    6 {

    7     public string Author { get; set; }

    8     public string Title { get; set; }

    9 }

   10 

   11 public class BookBusinessObject

   12 {       

   13     private static List<Book> books;

   14 

   15     public Book CurrentBook { get; set; }

   16 

   17     public virtual List<Book> ListOfBooks()

   18     {

   19         if (books == null)

   20         {

   21             InitializeBooks();

   22         }

   23 

   24         return books;

   25     }

   26 

   27     private static void InitializeBooks()

   28     {

   29         List<Book> bookList = new List<Book>();

   30         bookList.Add(new Book { Title = "Microsoft .NET: The Programming Bible", Author = "O'Brien, Tim" });

   31         bookList.Add(new Book { Title = "XML Developer's Guide", Author = "Gambardella, Matthew" });

   32 

   33         books = bookList;

   34     }

   35 

   36     public bool IsBookInList()

   37     {

   38         foreach (Book b in ListOfBooks())

   39         {

   40             System.Console.WriteLine(b.Title);

   41         }

   42 

   43         return this.ListOfBooks().Contains(this.CurrentBook);

   44     }

   45 }

   46 

   47 [TestFixture]

   48 public class BookBusinessObjectTest

   49 {

   50     [Test]

   51     public void IsBookInList_BookInList_ReturnTrue()

   52     {

   53         // arrange

   54         List<Book> fakeBookList = new List<Book>();

   55         Book fakebook = new Book { Title = "TDD for dummies", Author = "John Doe" };

   56         fakeBookList.Add(fakebook);

   57 

   58         BookBusinessObject businessObjectMock = MockRepository.GenerateStub<BookBusinessObject>();

   59         businessObjectMock.Stub(x => x.ListOfBooks()).Return(fakeBookList);

   60 

   61         businessObjectMock.CurrentBook = fakebook;

   62 

   63         // act

   64         bool included = businessObjectMock.IsBookInList();

   65 

   66         // assert

   67         Assert.IsTrue(included);

   68     }

   69 }

Der hviles et par minutter på laurbærerne inden næste test skrives. Det er ikke nok at vide om en given bog findes i listen, der skal også tilføjes andre valideringsregler så der kan med fordel oprettes en IsValid() metode, der kalder alle business objektets valideringsregler og giver en samlet vurdering af om business objektet er valid.

I unit testen for IsValid() kan det være en fordel at vi i stedet for at lave en fakeBookList blot laver et fake metodekald til IsBookInList() der altid returnerer true. Dermed skal der ikke sættes en masse op for at test IsValid(), hvis vi antager at IsBookInList() blot er én af mange ting, der skal være opfyldt.

Så for at Rhino Mocks kan fake retursvaret fra IsBookInList() ændrer vi accessoren i IsBookInList() fra "public" til "public virtual", men hov!! Det giver problemer, for nu siger NUnit;

TestCase 'BookBusinessObjectTest.IsBookInList_BookInList_ReturnTrue' failed:
  Expected: True
  But was:  False

0 passed, 1 failed, 0 skipped, took 2,64 seconds (NUnit 2.4).

Og det store spørgsmål er; hvorfor nu det?!?

Hjælpelinjerne 38-41 angiver, at fakeBookList ikke bliver injekteret da ListOfBooks() er tom. Ændres accessoren i stedet til "internal virtual" så er testen igen OK. Hvor er det at filmen knækker? Og hvad er best practice?

ne0san har prikket lidt til mig på det seneste med slet skjulte hentydninger til at få opdateret med et nyt indlæg, og da jeg tidligere på ugen skulle lave en simuleret test af belastningen på vores servere ved mange samtidige brugere, fik jeg en anledning til at komme lidt i gang med et indlæg, som jeg et stykke tid har tænkt på at få skrevet.

I modsætning til ne0san og t4rzan bliver det meste af den kode, jeg skriver, anvendt i asp.net og meget test er derfor test af GUI, forskellige browsere etc. Der er mange gode ting ved unit-testing, men jeg har stadig et web-projekt til gode, hvor unit-testing udgør en væsentlig del af testningen. I stedet går der rigtig meget tid med at klikke, ændre, klikke, forholde sig til feedback på skærmen osv. I nogle formularer kan vi have op mod 200 inputfelter på en side, der udløser validering, beregner delsummer etc., og her kan det være optimalt med en automatiserede test til at udfylde felter og klikke på knapper, så man blot skal forholde sig til, om fx en javascript funktion regner rigtigt.

Det er lige præcis hvad WatiN kan! WatiN er et .Net framework til at automatisere tests i Internet Explorer og Firefox (samt snart Chrome). Det er enormt let at komme i gang med - en reference til WatiN.Core.dll, en "using WatiN.Core;" og så er man kørende. Her fra er det "blot" at kode de linjer, der skal til for at udfylde felter, klikke på knapper, etc. Desværre er det ikke altid, at man bare kan referere til ID på en kontrol. Det findes måske ikke, eller kontrollen bliver genereret runtime eller lign., men heldigvis stiller WatiN en masse FindBy-metoder til rådighed, når kontrollerne skal findes. I eksemplet nedenfor refereres fx direkte til ID, der anvendes "alt" attributten, RegEx m.m. - jeg har med vilje gjort det mere omstændigt end nødvendigt for at komme lidt mere rundt i frameworket.

Indtil videre har jeg klaret mig med IE Developer Toolbar og Web Developer Extension til Firefox til at finde gode referencer til kontrollerne, men der findes faktisk et WatiN Test Recorder projekt, der gør det endnu lettere at komme i gang. Jeg har ikke selv prøvet det, men interessant ser det ud - måske kan det på en smart måde kombineres med IronPython som ne0san har leget med på det sidste?

Eksemplet nederst på siden gør følgende;

  1. Åbner et nyt Internet Explorer vindue
  2. Navigerer til http://www.asp.net/ajax
  3. Udfylder ValidatorCallout eksemplet på siden
  4. Skriver resultatet i et konsolvindue.

Når WatiN udfylder felter og klikker på kontroller bliver disse highlighted, så man kan se hvor den er i gang. Tekstfelter bliver udfyldt ét tegn af gangen, lige som når man selv sidder og taster, og derved er det let at følge med i hvorvidt valideringsregler etc. udløses korrekt.

Her følger så selve koden, der i sig selv viser et meningsløst eksempel, men bl.a. illustrerer hvor uhyggeligt let det er at lave en dims, der kan spamme en formular Wink

    1 using System;

    2 using System.Collections.Generic;

    3 using System.Text;

    4 using System.Text.RegularExpressions;

    5 using WatiN.Core;

    6 

    7 namespace WatinExample

    8 {

    9     class Program

   10     {

   11         [STAThread]

   12         static void Main(string[] args)

   13         {

   14             // Åben en ny Internet Explorer

   15             IE ie = new IE();

   16 

   17             // Maksimer browservinduet, så vi kan se hvad vi laver..

   18             ie.ShowWindow(NativeMethods.WindowShowStyle.ShowMaximized);

   19 

   20             // Gå til AJAX siden på www.asp.net

   21             ie.GoTo("http://www.asp.net/ajax");

   22 

   23             // Klik på billedet "View the Control Toolkit"

   24             // Billedet har ingen ID, så vi finder det ved

   25             // at søge efter den alternative tekst for billedet,

   26             // som heldigvis findes

   27             ie.Image(Find.ByAlt("View the Control Toolkit")).Click();

   28 

   29             // Klik på billedet "View the Control Toolkit, Live"

   30             // Billedet har ingen ID, så denne gang finder vi

   31             // det via en RegEx, der søger efter filnavnet,

   32             // (go-toolkit.gif), der er angivet i Src-tag:

   33             // http://static.asp.net/asp.net/images/ajax/go-toolkit.gif

   34             Regex regex = new Regex("(go-toolkit.gif)");

   35             ie.Image(Find.BySrc(regex)).Click();

   36 

   37             // Klik på linket med ID="ctl00_SamplesLinks_ctl34_SamplesLink"

   38             // (ValidatorCallout eksemplet)

   39             ie.Link("ctl00_SamplesLinks_ctl34_SamplesLink").Click();

   40 

   41             // ValidatorCallout eksemplet tager to input-felter (navn og telefonnr)

   42             // og returner med et svar fra en webservice, når der klikkes på en

   43             // knap. Vi udfylder de to felter og klikker på knappen "Submit".

   44             ie.TextField("ctl00_SampleContent_NameTextBox").TypeText("pandasan");

   45 

   46             Random rnd = new Random();

   47             string phoneNumber = string.Format("({0}) {1}-{2}", rnd.Next(100, 999), rnd.Next(100, 999), rnd.Next(1000, 9999));

   48             ie.TextField("ctl00_SampleContent_PhoneNumberTextBox").TypeText(phoneNumber);

   49             ie.Button(Find.ByValue("Submit")).Click();

   50 

   51             // Venter på svar fra webservice, så vi sover lidt imens..

   52             System.Threading.Thread.Sleep(5000);

   53 

   54             // Skriv svaret i konsollen

   55             Console.WriteLine(ie.Span("ctl00_SampleContent_lblMessage").Text);

   56 

   57             // Smil til kameraet..

   58             ie.CaptureWebPageToFile(@"c:\temp\screenshot.jpg");           

   59 

   60             // Luk Internet Explorer

   61             ie.Close();

   62 

   63             Console.ReadKey();

   64         }

   65     }

   66 }