Unit Testing 101

Følgende indlæg kan selvfølgelig på ingen måde siges at være dækkende for emnet "unit testing", men læs det som en hurtig introduktion til hvorfor unit testing er relevant, best practices og hvorfor unit testing ikke må blive en religion forbeholdt de udvalgte.

Motivation

 Du har sikkert skulle teste koden du afleverer i din afdeling. Om ikke andet så fordi det er træls at få koden tilbage i hovedet med besked om at rette fejlene og ikke vende tilbage "før alle fejl er rettet". Men måske også fordi du har indset, at det at teste din kode er mindst lige så vigtig en aktivitet som det at forfatte koden. En holdning som, hvis du deler den med dine kollegaer, gør det muligt for jer at I kan bevæge fremad langt mere pålideligt end hvis I undlod at teste. Et software projekt kan i løbet af kort tid være kørt så langt af sporet med kodefejl og dårligt design, at der skal startes forfra – og det koster! Ok så; vi bør teste vores kode som en integreret del af de refaktoreringer, der samlet frembringer den tænkte implementation!

Test specifikationer

 I "gamle dage" forfattede vi lange epistler, test specifikationer / rapporter, som når de blev gennemført og udfyldt gav os et solidt fundament at fortsætte videre på. Det er bedre, at arbejde på et kendt grundlag af fejl end at udvikle ny kode ovenpå et ukendt udgangspunkt. Problemet med dokumenterne er blot at de kræver enorme mængder af tid at vedligeholde / reviewe og frigive. Derfra udspringer tanken om at automatisere denne besværlige arbejdsgang således at selve vedligeholdelsen foregår sideløbende med udviklingen af koden, ja måske skrives test koden ligefrem før produktionskoden (læs senere om TDD). Koden skal testes som en integreret del af bygget og frigives kun når testene ikke fejler. Findes der efterfølgende fejl dækkes disse med nye automatiserede  tests, hvor det kan lade sig gøre.

 Typisk vil automatiserede tests ikke helt erstatte test specifikationerne idet niveauet på hvilket vi tester kan diktere en "manuel" test. Det kan f.eks. være i embeddede omgivelser, hvor fysiske stimuli er vanskelige at simulere eller i situationer, hvor det at forfatte integrationstests simpelthen er for omfattende, måske hvis du starter ovenpå en legacy kode base, som ikke er skrevet med automatiseret test in mente.

Test framework

Som udgangspunkt er en automatiseret test case blot en metode som en "test runner" kan afvikle.

 Hvilken test runner du benytter afhænger af det valgte xUnit framework. Du kan såmænd selv skrive dit eget framework med tilhørende runner uden stort besvær. Blot skal du implementere Assert utility klasser, definere passende Attribute klasser og reflecte over disse på behørig vis. Denne artikel tager udgangspunkt i nunit (http://nunit.org), men kan smertefrit tilpasses til et framework efter eget valg.

Arrange-Act-Assert

 Test metoderne (cases) specificerer forudsætningerne for testen, udførelsen af selve testen og verifikationen af resultatet. Denne tredeling kalder man gerne Arrange-Act-Assert.

 Se først følgende definitioner:

public interface ISheepCountable
{
   int NumberOfSheeps { get; }
}

public class Field: ISheepCountable { public int NumberOfSheeps { get; set; } }

public class Shepherd
{
   private readonly IEnumerable<ISheepCountable> sheepCounters;
   public Shepherd(IEnumerable<ISheepCountable> sheepCounters)
   {
      this.sheepCounters = sheepCounters;
   }

   public int CountSheep()
   {
      return sheepCounters.Sum(sheepCounter => sheepCounter.NumberOfSheeps);
   }
}

Vi kan da teste klassens evne til at tælle får i et TestFixture (efter hardware-verdenens pendant) med følgende test case:

[TestFixture]
public class TestShepherd
{
   [Test]
   public void TestShepherdCanCountSheep()
   {
      // Arrange
      var counters = new List
      {
         new Field { NumberOfSheeps = 1 },
         new Field { NumberOfSheeps = 2 },
         new Field { NumberOfSheeps = 3 }
      };

      var sut = new Shepherd(counters); // sut = Subject-Under-Test

      // Act
      var count = sut.CountSheep();

      // Assert
      Assert.AreEqual(6, count, "Number of sheeps not as expected");
   }
}

Skriver du dine tests i denne stil er det også lettere for andre at vedligeholde koden. Pointen er nok, at det vigtigste er at have en stil og så fastholde den konsekvent i projektet / organisationen.

Isolerede tests

Hvis du er en erfaren unit tester sidder du sikkert med pegefingeren i vejret: "Bør du ikke sørge for at dine tests er isolerede"? Jo, det er rigtigt at i Arrange delen af testen ovenfor bruger jeg min viden om sammenhængen mellem Shepherd og Field til at oprette et dummy counters test objekt (som skal bruges som input til Shepherd). Dette er skidt fordi en fejl i Field koden bør fanges af en Field kode test og ikke en Shepherd test. Endvidere kan vi forestille os, at vi på et tidspunkt bestemmer os for at modellere sammenhængen anderledes og se på "marker" ikke som grønne arealer, men som indhegninger og at det ikke længere er Fields som tilvejebringer tælle funktionaliteten, så skal denne test kode skrives om. Testen er ikke isoleret.

Stubbing

En måde at isolere testen af Shepherd er at anvende enten en stub eller en mock. Begge disse objekter erstatter rigtige objekter ved at efterligne objektet og implementationen af dets grænseflade(r). Således kunne vi skrive en stub klasse som implementerer ICanCountSheeps (NB: Bemærk iøvrigt den snedige brug af det udskældte "I" præfix, levn fra Hungarian notation) i stil med f.eks:

public class SheepCountStub: ISheepCountable
{
   private readonly int count;
   public SheepCountStub(int count)
   {
       this.count = count;
   }

   public int NumberOfSheeps
   {
      get { return count; }
   }
}

Testen kan da skrives som:

[Test]
public void TestShepherdCanCountSheepUsingStubs()
{
   var counters = new List
   {
      new SheepCountStub(1),
      new SheepCountStub(2),
      new SheepCountStub(3)
   };

   var sut = new Shepherd(counters);
   var count = sut.CountSheep();
   Assert.AreEqual(6, count, "Number of sheeps not as expected");
}

Testkoden afhænger nu kun af en (test) stub klasse, men løsningen er stadig ikke helt god - hvorfor ikke?

Mocking

Hver eneste gang ISheepCountable interfacet ændrer signatur, f.eks. ved tilføjelsen af nye metoder, skal stub klassen og de tilhørende test cases tilrettes tilsvarende. Dette kan være skidt (men er det ikke nødvendigvis, måske kræver ændringen at stubben ændrer adfærd) og derfor kan vi i stedet anvende mock objekter til at isolere vore tests. Mocking frameworks hjælper til med at wrappe snitflader og erstatte metode kald med forventninger, som angiver f.eks. en ønsket returværdi.

Se følgende eksempel som benytter sig af Moq frameworket (http://code.google.com/p/moq/) til at mocke ISheepCountable funktionaliteten.

[Test]
public void TestShepherdCanCountSheepUsingMocks()
{
   // Arrange
   var mockSheepCounter1 = new Mock<ISheepCountable>();
   mockSheepCounter1.Setup(counter => counter.NumberOfSheeps).Returns(1);
   var mockSheepCounter2 = new Mock<ISheepCountable<();
   mockSheepCounter2.Setup(counter => counter.NumberOfSheeps).Returns(2);
   var mockSheepCounter3 = new Mock<ISheepCountable>();
   mockSheepCounter3.Setup(counter => counter.NumberOfSheeps).Returns(3);
   var counters = new List
   {
      mockSheepCounter1.Object,
      mockSheepCounter2.Object,
      mockSheepCounter3.Object,
   };

   var sut = new Shepherd(counters);

   // Act
   var count = sut.CountSheep();

   // Assert
   mockSheepCounter1.Verify();
   mockSheepCounter2.Verify();
   mockSheepCounter3.Verify();
   Assert.AreEqual(6, count, "Number of sheeps not as expected");
 }

Refaktorering og TDD

Som for produktionskode er det et vigtigt skridt også at refaktorere koden så DRY (Don't-Repeat-Yourself) overholdes.

Oplagt er det at skrive følgende (find selv på flere refaktoreringer):

[Test]
public void TestShepherdCanCountSheepUsingMocksRefactored()
{
   // Arrange
   var mockSheepCounter1 = CreateMockSheepCounter(1);
   var mockSheepCounter2 = CreateMockSheepCounter(2);
   var mockSheepCounter3 = CreateMockSheepCounter(3);
   var counters = new List
   {
      mockSheepCounter1.Object,
      mockSheepCounter2.Object,
      mockSheepCounter3.Object,
   };

   var sut = new Shepherd(counters);

   // Act
   var count = sut.CountSheep();

   // Assert
   mockSheepCounter1.Verify();
   mockSheepCounter2.Verify();
   mockSheepCounter3.Verify();
   Assert.AreEqual(6, count, "Number of sheeps not as expected");
}

private static Mock CreateMockSheepCounter(int count)
{
   var mockSheepCounter = new Mock<ISheepCountable>();

   mockSheepCounter.Setup(counter => counter.NumberOfSheeps).Returns(count);
   return mockSheepCounter;
}

Faktisk er refaktoreringsskridtet og mocking tankegangen væsentlige værktøjer i test driven development (TDD).

Her består test skrivningsdisciplinen i at tage udgangspunkt i at skrive testen og derefter / sideløbende produktionskoden. Cyklusen kan opsummeres med trafiklysterminologi:

Rød: Først skrives en test som fejler (puritanere mener at også compileringsfejl tæller)

Grøn: Dernæst skrives den mindste mængde kode som skal til for at testen bliver grøn

Gul: Sidste skridt er at refaktorere den skrevne kode, så testene / produktionskoden er let at vedligeholde og selvfølgelig køre testene igen efterfølgende (alle tests)

Dernæst startes forfra med udvidelse af testen og tilføjelse af nye tests.

Den væsentlige pointe omkring brugen af mocks og TDD er at mocking tillader og muliggør det at skrive testen af ISheepCountable / Shephard funktionaliteten inden vi faktisk har implementeret den.

Afrunding

Ovenstående pointer er som sagt kun nogle få af mange ideer til, hvordan du kommer videre med automatiseret test. Der er rigtig mange meninger om hvad der er rigtigt og forkert, men lad endelig ikke dette stoppe dig fra at få begyndt. Automatiserede tests er til for at sikre du kan skrive brugbar kode, ikke som en disciplin som kun må udøves af de særligt indviede eksperter.

Add comment

Loading