TDD og mocking i praksis


søndag 30. august 2009 Testing C#

I de to forrige postene, Utenfra-og-inn programmering og Avhengigetsisolering (a.k.a Mocking), har jeg forklart hvordan jeg praktiserer testdreven utvikling. Denne gangen gjør jeg et forsøk på å vise det i praksis, en slags "tutorial" om du vil. Det er selvfølgelig en overhengende fare for at jeg produserer en svært lang blogpost, men om du har lyst til å bli med meg og oppleve "Mockist TDD" i praksis så er du i alle fall svært velkommen.

Jeg dokumenterer steg for steg hva jeg gjør, og om du vil må du gjerne kode oppgaven parallelt med meg – det vil du nok lære mest av. I så fall trenger du NUnit og Moq (eller annet testrammeverk og mockingrammeverk du er komfortabel med), db4objects, samt Visual Studio. Jeg har ikke lagt ved den ferdige løsningen, det er selve prosessen som er poenget her.

Når jeg gjør testdreven utvikling forsøker jeg å utvikle i så små steg som mulig, og jeg hopper mye frem og tilbake mellom test og produksjonskode. Jeg har også en tendens til å foretrekke mange, små klasser som jobber sammen for å løse oppgaver, til fordel for større klasser med mer kompleks logikk. Noen vil kalle det overkill, jeg kaller det et fleksibelt design. Du kan gjøre deg opp din egen mening.

Oppgaven

"På vår fantastiske webside skal man kunne registrere seg som ny bruker med brukernavn og passord. Passordet skal selvsagt lagres kryptert. Når man har en brukerkonto skal man kunne logge inn."

Ok, det hørtes ikke så vanskelig ut – de fleste utviklere har gjort dette noen ganger før. Asp.net har jo også kontroller og providere som gjør dette veldig enkelt. Men vi skal bruke denne oppgaven til å øve på TDD, så vi later som om alt det der ikke finnes, glemmer hva vi har gjort tidligere, og setter i gang.

En liten påminnelse: Det finnes hundrevis av måter å implementere dette på, og det finnes hundrevis av måter å gjøre TDD på. Dette er min fremgangsmåte, slik jeg foretrekker akkurat nå. Det finne singen fasit, og neste gang ville jeg kanskje ha gjort det anderledes. Så ikke kopier meg, men reflekter over hvorfor jeg gjør det jeg gjør, og du vil sikkert lære noe på veien.

Forberedelser

Jeg fyrer opp Visual Studio og åpner et nytt prosjekt basert på Asp.net Web Application templaten. Vi innbiller oss at dette er den eksisterende websiten vi skal utvikle den nye "featuren" for. For å gjøre dette enkelt vil alt opprettes i dette ene prosjektet, inkludert testene – normalt ville jeg ha fordelt elementene i løsningen i diverse moduler.

Den første testen, en kontroller og et view

Oppgaven vi har fått er todelt: Brukerregistrering og brukerautentisering. Registrering kommer naturlig før autentisering, så vi starter med det. Er du en utvikler som ikke er vandt til TDD og utenfra-og-inn programmering kan det nå være fristene å tenke at du har lyst til å starte med å opprette en bruker-klasse, eller kanskje definere hvordan databasen skal se ut. Det skal vi ikke gjøre. Ditt første instinkt skal være å skrive en test, en test som passerer om brukerregistreringen fungerer.

La oss opprette en testklasse – jeg velger å gi den det merkelige navnet When_registering_a_new_user:

    9 [TestFixture]

   10 public class When_registering_a_new_user

   11 {

   12 }

Jeg ser for meg at jeg vil ha en eller annen form for controller som tar seg av registreringen. Controlleren kan kommunisere med et view hvor brukeren skal taste inn et brukernavn og passord og trigge registreringen. Når registreringen er gjennomført kan controlleren kommunisere dette tilbake via viewet. La meg forsøke å skrive en test:

    9 [TestFixture]

   10 public class When_registering_a_new_user

   11 {

   12     [Test]

   13     public void Should_report_registration_ok_in_view()

   14     {

   15         var view = new UserRegistrationView();

   16         var controller = new UserRegistrationController(view);

   17         view.TriggerRegistrationRequest("Kong Håkon", "1234");

   18         Assert.IsTrue(view.RegistrationOk);

   19     }

   20 }

Ingen av disse klassene finnes enda. Dette er helt vanlig når man gjør TDD; man forsøker først å finne ut hvordan klassene bør se ut – deretter implementerer man dem.

Jeg er ikke helt fornøyd med hvordan jeg må trigge registration request ved å kalle en metode på viewet, og jeg like heller ikke at jeg må implementere to ulike klasser for denne testen – jeg ønsker å fokusere på én ting om gangen. Men hvis jeg foreløpig lar viewet være et abstrakt interface, og tar i bruk mockingrammeverket (Moq) kan vi få dette til å bli litt bedre. Jeg endrer testen:

   13 [Test]

   14 public void Should_report_registration_ok_in_view()

   15 {

   16     var mockView = new Mock<UserRegistrationView>();

   17     var controller = new UserRegistrationController(mockView.Object);

   18     mockView.Raise(view => view.RegistrationRequested += null, EventArgs.Empty);

   19     mockView.Verify(view => view.SetRegistrationResult(true), Times.Once());

   20 }

Det vil være mye rødt i editoren nå siden det fortsatt ikke er noen av disse klassene, metodene eller eventene som eksisterer, men det lærer vi oss raskt å leve med, så forsøk å se bort i fra det.

Denne testen synes jeg ble litt bedre. På linje 18 trigger jeg et RegistrationRequested event, som jeg tenker vil tilsvare eventet som fyres når brukeren klikker på "register" knappen på websiden. På neste linje verifiserer jeg at SetRegistrationResult har blitt kalt med argumentet "true". Jeg vil nemlig at controlleren skal plukke opp registreringsforespørselen og kalle den metoden om alt går som det skal. I stedet for å verifisere på tilstand verifiserer jeg nå adferd.

Det er på tide å gjøre noe med alle de røde advarslene, så nå vil jeg opprette controlleren og viewet. Siden jeg bruker CodeRush er dette kun noen få tastetrykk – bruker du ikke noe slikt verktøy må du gjøre det for hånd. Dette er hva jeg kommer opp med.

   10 public interface UserRegistrationView

   11 {

   12     event EventHandler RegistrationRequested;

   13     void SetRegistrationResult(bool success);

   14 }

   15 public class UserRegistrationController

   16 {

   17     private UserRegistrationView _view;

   18     public UserRegistrationController(UserRegistrationView view)

   19     {

   20         _view = view;

   21     }

   22 }

Nå kompilerer prosjektet vårt, men kjører jeg testen vil den selvfølgelig feile, for SetRegistrationResult vil aldri bli kalt. Og det er helt perfekt. I TDD følger man et mønster som heter "Red, Green, Refactor", som innebærer at vi først skal sørge for å lage en test som feiler. Det har vi oppnådd.

En grønn test

Så hvordan kan vi komme til neste steg – en vellykket test? Jo, det er veldig enkelt, selv om det virker som en helt idiotisk ting å gjøre:

   18 public UserRegistrationController(UserRegistrationView view)

   19 {

   20     _view = view;

   21     view.SetRegistrationResult(true);

   22 }

Jeg kaller rett og slett SetRegistrationResult i konstruktøren til controlleren. Hvorfor det? Jo, nå vet jeg at testen fungerer – at testen blir grønn hvis SetRegistrationResult kalles. To av tre steg er gjennomført, og vi kan gå igang med det litt vage steget "refakturering".

Men hva med eventet som trigges? Det var jo det som skulle føre til registreringen. Stemmer det! Jeg kunne ha skrevet en ny test som passet på at SetRegistrationResult ikke ble kalt uten at eventet ble trigget, men akkurat nå synes jeg ikke det er nødvendig. Jeg gjør denne endringen med den ene testen jeg har:

   15 public class UserRegistrationController

   16 {

   17     private UserRegistrationView _view;

   18     public UserRegistrationController(UserRegistrationView view)

   19     {

   20         _view = view;

   21         _view.RegistrationRequested += _view_RegistrationRequested;

   22     }

   23 

   24     void _view_RegistrationRequested(object sender, EventArgs e)

   25     {

   26         _view.SetRegistrationResult(true);

   27     }

   28 }

.. jeg kjører testen på nytt etter endringen for å være sikker på at jeg ikke har ødelagt noe; den er fortsatt grønn, så alt er ok.

Test nummer 2: User repository

Men det skjer jo ingen registrering her. Hmmm. En registrering innebærer å opprette og lagre en ny bruker, ikke sant?! (Som du ser spiller jeg dum, og forplikter meg dermed ikke til et konkret design før det er absolutt nødvendig.) Ok, da trenger vi en eller annen form for Repository som kan lagre nye brukere. Jeg får skrive en ny test – kan jo ikke begynne å implementere noe nytt uten…

   40 [Test]

   41 public void Should_persist_user_in_repository()

   42 {

   43     var mockView = new Mock<UserRegistrationView>();

   44     var mockRepository = new Mock<UserRepository>();

   45     var controller = new UserRegistrationController(mockView.Object, mockRepository.Object);

   46     mockView.Raise(view => view.RegistrationRequested += null, EventArgs.Empty);

   47     mockRepository.Verify(r => r.Insert(It.IsAny<User>()));

   48 }

Jeg lager en ny mock som skal representere en repository, og sender den også inn til controlleren. I linje 47 kontrollerer jeg at Insert har blitt kalt, og at det ble sendt inn et User objekt. Hverken UserRepository eller User eksisterer enda.

Det meste av testen ligner på den forrige testen, så DRY-prinsippet (kanskje det aller viktigste prinsippet innen softwareutvikling) tvinger meg til å trekke ut det som er felles. Jeg ender opp med denne testklassen:

   29 [TestFixture]

   30 public class When_registering_a_new_user

   31 {

   32     [Test]

   33     public void Should_report_registration_ok_in_view()

   34     {

   35         When_doing_a_registration();

   36         mockView.Verify(view => view.SetRegistrationResult(true), Times.Once());

   37     }

   38     [Test]

   39     public void Should_persist_user_in_repository()

   40     {

   41         When_doing_a_registration();

   42         mockRepository.Verify(r => r.Insert(It.IsAny<User>()));

   43     }

   44     private void When_doing_a_registration()

   45     {

   46         mockView = new Mock<UserRegistrationView>();

   47         mockRepository = new Mock<UserRepository>();

   48         controller = new UserRegistrationController(mockView.Object, mockRepository.Object);

   49         mockView.Raise(view => view.RegistrationRequested += null, EventArgs.Empty);

   50     }

   51     private Mock<UserRepository> mockRepository;

   52     private Mock<UserRegistrationView> mockView;

   53     private UserRegistrationController controller;

   54 }

For at dette skal kompilere må jeg gjøre tre ting; opprette UserRepository, fikse controllerens konstruktør til å ta inn en repository, og opprette en User klasse. Noen CodeRush-shortcuts senere har jeg opprettet følgende:

   15 public class User

   16 {

   17 }

   18 public interface UserRepository

   19 {

   20     void Insert(User userToInsert);

   21 }

   22 public class UserRegistrationController

   23 {

   24     private UserRepository _repository;

   25     private UserRegistrationView _view;

   26     public UserRegistrationController(

   27         UserRegistrationView view, UserRepository repository)

   28     {

   29         _repository = repository;

   30         _view = view;

   31         _view.RegistrationRequested += _view_RegistrationRequested;

   32     }

Prosjektet kompilerer igjen. Vår første test passerer fortsatt, men den nye testen gjør ikke det, så det får vi gjøre noe med. Vi må rett og slett sørge for at den nye brukeren lagres:

   33 private void _view_RegistrationRequested(object sender, EventArgs e)

   34 {

   35     _repository.Insert(new User());

   36     _view.SetRegistrationResult(true);

   37 }

Der, nå er begge testene grønne. Kontrolleren lagrer en ny bruker til repositorien, og sier fra til viewet at det er gjort. Men den mangler noe veldig sentralt. Trenger vi ikke noe mer input? Hvor er brukernavnet og passordet?

Har du noen gang programmert så mye adferd uten å lage en eneste public property? Er det ikke interessant hvordan TDD lar oss fokusere på adferd? Men nå er det kanskje på tide å legge til i alle fall brukernavn og passord. Men først må vi ha en test for dette. Den siste testen vi skrev verifiserer at brukeren lagres til basen. Hva om vi legger til å validere selve brukeren som legges til også – at den har riktig navn f.eks.? Jeg forsøker meg på dette ved å endre testen til dette her:

   48 [Test]

   49 public void Should_persist_user_in_repository()

   50 {

   51     User addedUser = null;

   52     mockRepository

   53         .Setup(r => r.Insert(It.IsAny<User>()))

   54         .Callback((User user) => addedUser = user);

   55     When_doing_a_registration();

   56     Assert.AreEqual("Kong Håkon", addedUser.Username);

   57 }

Jeg bruker litt Moq-magic til å sette opp en callback som tar vare på brukeren som sendes til repositorien, og sjekker at navnet på den er "Kong Håkon". Men hvor har jeg fått dette navnet fra? Det skal jo brukeren sende inn.., via viewet selvfølgelig. Så vi setter opp dette også:

   50 [Test]

   51 public void Should_persist_user_in_repository()

   52 {

   53     mockView.SetupGet(v => v.Username).Returns("Kong Håkon");

   54     User addedUser = null;

   55     mockRepository

   56         .Setup(r => r.Insert(It.IsAny<User>()))

   57         .Callback((User user) => addedUser = user);

   58     When_doing_a_registration();

   59     Assert.AreEqual("Kong Håkon", addedUser.Username);

   60 }

Jeg forventer nå at viewet har en Username property, og at den vil returnere "Kong Håkon" for oss når den blir forespurt. Registreringsjobben skal da overføre dette navnet til brukeren som lagres. Peace of cake! Jeg oppretter de to propertiene jeg trenger for å få kompilert, kjører testen én gang for å se det røde lyset, implementerer litt mer logikk i kontrolleren, og har to grønne tester igjen.

PS: Jeg har forøvrig trukket ut litt logikk fra When_doing_a_registration() og plassert det i en SetUp-metode – hvis ikke hadde jeg fått en null-ref exception i linje 53.

   10 public interface UserRegistrationView

   11 {

   12     event EventHandler RegistrationRequested;

   13     string Username { get; }

   14     void SetRegistrationResult(bool success);

   15 }

   16 public class User

   17 {

   18     public string Username { get; set; }

   19 }

   35 private void _view_RegistrationRequested(object sender, EventArgs e)

   36 {

   37     _repository.Insert(new User

   38     {

   39         Username = _view.Username

   40     });

   41     _view.SetRegistrationResult(true);

   42 }

Passordkryptering

Nå kunne jeg gjort det samme for passord som for brukernavn, men oppgaven sa at jeg skulle kryptere passordet. Det er derfor viktig at passordet som lagres IKKE er det samme som sendes inn fra viewet. Jeg jobber litt til med testene:

   63 Assert.AreNotEqual("passw0rd", addedUser.Password);

Nei, dette føles ikke særlig riktig. Når jeg tenker over det er hashing av passord et helt annet ansvar, noe kontrolleren kan deligere til noen andre. Ved å utnytte det kan jeg skrive en enkel test som validerer at passordet hashes, mens jeg utsetter selve hash-logikken.

   77 [Test]

   78 public void Should_hash_password()

   79 {

   80     When_doing_a_registration();

   81     mockHasher.Verify(h => h.HashPassword(It.IsAny<string>()));

   82 }

Jeg har begynt å gå litt raskere frem nå, men jeg regner med at du klarer å henge med. Jeg har lagt til en ny avhengighet, PasswordHashService, som jeg injecter i controller-konstruktøren. Og så skriver jeg en ny test som verifiserer at HashPassword kalles. Noen sekunder senere får jeg dette til å kompilere og testen til å passere ved å gjøre følgende..

   39 private void _view_RegistrationRequested(object sender, EventArgs e)

   40 {

   41     _repository.Insert(new User

   42     {

   43         Username = _view.Username,

   44         Password = _hasher.HashPassword(_view.Password),

   45     });

   46     _view.SetRegistrationResult(true);

   47 }

Password settes i linje 44. Jeg bruker to ulike properties som ikke finnes enda.., så jeg oppretter dem. Done!

En konkret hasher

Vi har nå laget en fungerende controller, men ingen av de objektene den sammarbeider med eksisterer enda – de ble alle "mocket ut" i testene. Vi kan nå jobbe oss innover. Jeg velger å ta PasswordHashService først. Jeg skriver selvsagt tester før produksjonskode.., og her er den første:

   55 [TestFixture]

   56 public class MD5HasherTests

   57 {

   58     [Test]

   59     public void QuickBrownFoxTest()

   60     {

   61         Assert.AreEqual(

   62             "9e107d9d372bb6826bd81d3542a419d6",

   63             new MD5Hasher().HashPassword("The quick brown fox jumps over the lazy dog"));

   64     }

   65 }

Jeg fant hashen til denne setningen i Wikipedia's artikkel om MD5, så jeg tenkte den kunne være et greit utgangspunkt for å teste algoritmen. Litt cut n' paste fra nettet og prøving og feiling gir meg følgende implementasjon som passerer testen:

   53 public class MD5Hasher : PasswordHashService

   54 {

   55     public string HashPassword(string passwordToHash)

   56     {

   57         var passwordBytes = Encoding.UTF8.GetBytes(passwordToHash);

   58         var hashBytes = MD5.Create().ComputeHash(passwordBytes);

   59         var buffer = new StringBuilder();

   60         for (int i = 0; i < hashBytes.Length; i++)

   61             buffer.Append(hashBytes[i].ToString("x2").ToLower());

   62         return buffer.ToString();

   63     }

   64 }

En konkret repository

Neste interface jeg trenger en konkret implementasjon av er UserRepository. For å implementere den trenger jeg et persisteringsmedium, en eller annen form for database. Det enkleste jeg vet om er å bruke en objektdatabase, db4objects, så det vil jeg gjøre her. Jeg kan enkelt sette opp en test som oppretter en database, lagrer en bruker, kontrollerer at jeg kan hente brukeren ut igjen, og rydder opp ved å slette basen. Jeg gjør dette i små steg, men her er alt i en batch:

   44 [TestFixture]

   45 public class Db4oUserRepositoryTests

   46 {

   47     const string db_path = @"c:\temp\Db4oUserRepositoryTests.dbo";

   48     private IObjectContainer database;

   49     private Db4oUserRepository repository;

   50 

   51     [SetUp]

   52     public void SetUp()

   53     {

   54         database = Db4oFactory.OpenFile(db_path);

   55         repository = new Db4oUserRepository(database);

   56     }

   57     [TearDown]

   58     public void TearDown()

   59     {

   60         database.Dispose();

   61         File.Delete(db_path);

   62     }

   63     [Test]

   64     public void Should_insert_user()

   65     {

   66         repository.Insert(new User { Username = "Foo" });

   67         Assert.AreEqual(

   68             "Foo",

   69             (from User u in database select u).Single().Username);

   70     }

   71 }

Jeg vil ha en konkret UserRepository som jeg vil kalle Db4oUserRepository. Jeg vil sende en instans av en objektdatabase inn via konstruktøren (IObjectContainer er et db4objects interface). I setup-metoden oppretter jeg en ny filbasert database og selve repositorien, og i teardown disposer jeg databasen og sletter filen.

Selve testen lagrer en ny bruker i repositorien, og så bruker jeg Linq 2 Db4o for å hente ut alle brukere fra basen, sjekke at det finnes én-og-bare-én bruker (ved å kalle Single()), og kontrollere at brukernavnet er riktig. Vær oppmerksom på at du må legge til "using Db4objects.Db4o.Linq" for å få Linq-spørringen i linje 69 til å kompilere. Vil du vite mer om Db4objects kan du lese min blogpost om dette fantastiske rammeverket.

Følgende enkle implementasjon tilfredstiller testen over:

   27 public class Db4oUserRepository : UserRepository

   28 {

   29     private IObjectContainer _database;

   30     public Db4oUserRepository(IObjectContainer database)

   31     {

   32         _database = database;           

   33     }

   34     public void Insert(User userToInsert)

   35     {

   36         _database.Store(userToInsert);

   37     }

   38 }

Et konkret view

Det eneste som gjenstår nå for å ha en fungerende brukerregistrering er å lage et konkret view. Dette er den eneste biten jeg ikke bryr meg om å skrive tester for – det er somregel ikke verdt innsatsen, sålenge forretningslogikken er separert ut av viewet. Jeg oppretter en ny Web User Control, kaller den Registration.ascx, og dropper inn to TextBox'er og en Button.

    1 <%@ Control Language="C#" AutoEventWireup="true" CodeBehind="Registration.ascx.cs"

    2     Inherits="WebApplication1.Registration" %>

    3 User name: <asp:TextBox ID="txtUsername" runat="server"></asp:TextBox><br />

    4 Password: <asp:TextBox ID="txtPassword" runat="server"></asp:TextBox><br />

    5 <asp:Button ID="createUserButton" runat="server" Text="Create user"

    6     onclick="createUserButton_Click" />

Og her er code-behind, hvor jeg lar Registration arve fra UserRegistrationView:

   11 public partial class Registration : UserControl, UserRegistrationView

   12 {

   13     private UserRegistrationController _controller;

   14     protected void Page_Load(object sender, EventArgs e)

   15     {

   16         _controller = new UserRegistrationController(

   17             this,

   18             Application["UserRespoitory"] as UserRepository,

   19             new MD5Hasher());

   20     }

   21 

   22     // Inherited from UserRegistrationView

   23     public event EventHandler RegistrationRequested;

   24     public string Username { get { return txtUsername.Text; } }

   25     public string Password { get { return txtPassword.Text; } }

   26     public void SetRegistrationResult(bool success)

   27     {

   28         if (success)

   29         {

   30             createUserButton.Text = "Success!";

   31             createUserButton.Enabled = false;

   32         }

   33     }

   34     // end UserRegistrationView Members

   35     protected void createUserButton_Click(object sender, EventArgs e)

   36     {

   37         if (RegistrationRequested != null)

   38             RegistrationRequested(this, e);

   39     }

   40 }

Denne approchen er opprinnelig inspiret av Phil Haack's blogpost om Supervising Controller i ASP.NET. Initaliseringen av kontrolleren som foregår i Page_Load knytter sammen alle objektene som trengs for å registrere en bruker, og når brukeren fyrer av RegistrationRequested eventet ved å klikke på knappen vil controlleren ta over og gjennomføre registreringen.

Om du drar inn Registration-kontrollen i Default.aspx så kan du teste at det fungerer som det skal. Foreløpig får du tilbakemelding ved at knappen blir disablet og teksten sier "Success!", men her kan man tenke seg at vi skjuler hele kontrollen og viser login-kontrollen (som fortsatt gjenstår å lage) i stedet.

Jeg har valgt å plassere repositorien i Application state – jeg vet ikke hvor bra dette fungerer i praksis, og det er ikke sånn jeg ville har gjort det "i virkeligheten", men det fungerer greit her og nå. ASP.NET og state er ikke det viktige temaet i denne artikkelen, så ikke gi meg flak for denne fremgangsmåten. Initaliseringen skjer i Global.asax:

    6 public class Global : System.Web.HttpApplication

    7 {

    8     protected void Application_Start(object sender, EventArgs e)

    9     {

   10         var database = Db4oFactory.OpenFile(@"c:\temp\Db4oUserRepository.dbo");

   11         Application["Db4oUserRepository.dbo"] = database;

   12         Application["UserRespoitory"] = new Db4oUserRepository(database);

   13     }

   14 

   15     protected void Application_End(object sender, EventArgs e)

   16     {

   17         var database = Application["Db4oUserRepository.dbo"] as IDisposable;

   18         database.Dispose();

   19     }

   20 }

Innlogging

For å implementere innlogging går jeg frem som jeg gjorde da jeg startet på brukerregistrering. Innlogging er et eget ansvar som trenger egne tester, eget view og egen controller. Controlleren vil bruke UserRepository, som man gjennom testene må utvide med logikk for å hente opp en bruker basert på navn. Den vil også gjenbruke PasswordHashService, fordi passordet må hashes før man kan sammenligne med det lagrede passordet.

Gjennom denne blogposten har du allerede sett og lært alt du trenger for å fullføre oppgaven, så jeg stopper her. Om du har lyst til å øve deg litt selv så anbefaler jeg at du fortsetter på egenhånd. Skriv tester først, og ikke lag en eneste klasse eller metode før du har kode (test eller annen kode) som allerede refererer klassen/metoden.

Jeg håper du likte denne TDD-tutorialen. I en ny blogpost som kommer veldig snart vil jeg analysere designet jeg har kommet opp med, og snakke litt om best practises rundt smidig arkitektur. På gjensyn!

Knagger: , , , , , , , , , ,


comments powered by Disqus