Normalt forventer man, at hvis a + b giver c, så vil a + b altid give c.  I programmering skal man have en vis tolerance mht. præcision, når man bruger doubles og floats, men hvis a + b giver c i et program, forventer man samme resultat, uanset hvornår man laver beregningen indenfor samme proces.

Sådan troede jeg det var indtil fornylig, hvor én af vores unit tests fejlede – tilsyneladende lidt tilfældigt.  For kørte jeg den pågældende unit test alene, var der ingen fejl.  Kørte jeg den sammen med de andre unit tests i projektet, fejlede den.  Jeg fik ret hurtigt identificeret, hvilke andre unit tests, der drillede, og den fejlende unit test havde absolut intet med de andre unit tests at gøre.  Der var ingen fælles kode.

For nemheds skyld vil jeg kalde den fejlende unit test for A, og kalde de andre unit tests, som forårsagede fejlen i A for B. 

Test A tester kode indeholdende komplekse beregninger (Nelder-Mead), og fejlen gjorde, at det endelige resultat endte med en difference på 0.5!  Det er meget i min verden.

Test B er lidt speciel.  Den tester funktionalitet til at indlæse data fra et regneark.  Dvs. koden, som B tester, benytter Microsoft’s ACE driver. Efter en del test af denne driver, og noget der ligner to dages riven i mit eget hår, var jeg i stand til at genskabe problematikken med et simpelt eksempel, hvor resultat af et gangestykke med doubles gave forskellige resultater før og efter kald til ACE driveren.

Et spørgsmål på StackOverflow affødte en forklaring: “… unmanaged code may be tinkering with the FPU control word and change the way it calculates”.  Der blev også foreslået en løsning, nemlig et kald til _fpreset, som “resets the floating point package”.

Den foreslåede løsning virker, men jeg føler mig ikke overbevist om, at jeg egentlig har lyst til at bruge ACE driveren direkte i vores produktionskode.  En rådslagning med Daniel (også kendt er på sitet som ne0san) førte frem til en anden løsning, nemlig at spawn en ny process, som indlæser data fra regnearket, og kommunikerer data tilbage til hovedprocessen vha. named pipes (måske et emne for en kommende dotninjas blog).  Det virker.  Om det er en bedre løsning end _fpreset er svært at sige.

Hvis du har en masse test data i en Excel fil er der ingen grund til at kopiere det ind i dine tests - xUnit extensions kan ordne det for dig. Det er endda på en mege elegant måde.

Jeg har noget test data fra et gammel system som indeholder nogle tal for forskellige aldre. F.eks.

Age, Gamma, Sigma, Lambda, S

Jeg har 130 aldre i skridt på et kvart år. Hvert af de øvrige koloner svarer til det ønskede resultat af en funktion med samme navn givet alderen. Der findes altså funktioner Gamma(age), Sigma(age), Lambda(age) og S(age) på min klasse. Jeg vil gerne teste disse funktioner i hver deres test metode - ikke noget med at teste alle mulige funtioner i samme test metode (fy fy).

Det første du skal gøre er at finde dit test data frem i Excel og navngive området - inkl. kolonenavne. Enten ved at du bruger navneboksen til venstre for formellinjen eller "Name Manager" under "Formulas" (Du skal bruge "Name Manager" hvis du vil ændre eller slette navne). Dernæst gemmer du dit test data i Excel 97-2003 format (*.xls). Jeg har ikke fået det til at virke med det nye fancy pancy format.

Nu skriver du dine test metoder og klistrer de magiske attributer på:

[Theory]
[ExcelData("MyData.xls", "SELECT Age, Gamma FROM TestArea51")]
public void Gamma_Test(double age, double expected)
{
  // TODO: Place tests here (sorry about the methodname)
  var actual = ...

  Assert.Equal(expected, actual);  
}

Begge attributer kræver Xunit.Extensions som har sin egen Nuget pakke (og namespace med samme navn).

Det lille fancy SQL statement er muligt fordi vi har valgt at kolonenavne skal indgå i TestArea51 området. Så slipper vi for at ændre vores test hvis vi senere ønsker at udvidere området med mere test data. Bemærk at Excel navne svarer til et område inkl. arknavnet. Man kan altså ikke have samme område navn på forskellige ark - tilgengæld skal man heller ikke angive arknavnet i attributten ovenfor.

Når du kører testen vil den blive kaldt for hver række i dit test data - også selvom nogle rækker skulle fejle. Input data fremgår i test rapport hvis det fejler, så det er nemt at finde de rækker som ikke lever op til dine krav. Vær opmærksom på at GUI test runneren godt kan kløjs lidt i det hvis alt for mange rækker fejler. Jeg har med et snuptag fået 2000+ test bare på dette data - det er ret meget output hvis alle tests fejler.

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?

UPDATE: I stand corrected.  Belært af Mark og Rasmus (se kommentarer nedenfor) har jeg opdateret mit kodeeksempel med nyeste Rhino Mocks funktionalitet.  Tak for det.

Jeg ser flere og flere referencer til AAA på nettet (se f.eks. RasmusKL og Roy Osherove).  Jeg har indtil nu været ganske tilfreds med Record/Playback metoden for Rhino Mocks, men jeg kan sagtens se pointen bag AAA-stilen, så det er tid til at skifte vane.

Jeg har indtil fornylig brugt Rhino Mocks v3.4.  Det viser sig, at der lidt udfordringer i at følge AAA med den måde, Rhino Mocks 3.4 fungerer.  Problemet ligger i måden, man sætter expectations op.

Antag vi har følgende interface ITest, som vi ønsker at mocke:

public interface ITest
{
    int DoStuff(int a);
}

 

Vi kan sætte expectations op på følgende måde:

ITest testMock = mocks.CreateMock<ITest>();
Expect.Call(testMock.DoStuff(11)).Return(42);

Expectations bør ligge i Arrange-blokken, men vi forventer, at DoStuff bliver kaldt med værdien 11, hvilket faktisk er en Assert.  Ønsker man at sætte yderliger constraints op på parametre, skal det også ske i Record fasen, som hører under Arrange-blokken, men constraints bør ligge i Assert-blokken.

Rhino mocks v3.5 giver en helt ny API, som gør, at vi nu kan skrive vores test på en helt anden måde (tak til Rasmus):

// Arrange
ITest testMock = MockRepository.GenerateStub<ITest>();
testMock.Stub(x =>; x.DoStuff(Arg<int>.Is.Anything)).Return(42);

// Act
testMock.DoStuff(11);

// Assert
testMock.AssertWasCalled(x => x.DoStuff(Arg.Is(11)));

Testen er nu delt op i de tre AAA-blokke som ønsket.