Strømlinjeformede enhetstester


torsdag 20. januar 2011 DSL TDD C#

Når man skriver enhetstester kan det fort bli mye rot om man ikke tenker nøye gjennom hvordan de bør struktureres. Det vanlige er å lage testmetoder som inneholder en eller annen form for Arrange-Act-Assert eller Given-When-Then; man setter altså opp et miljø som skal testes, utfører en handling, og sjekker at det man forventer har skjedd. Desverre blir dette ofte for mye støy i én metode, og man trenger et bedre “rammeverk” for testene.

Ingen situasjoner er like, og strukturen på testene bør derfor heller ikke alltid følge samme mal. I denne blogposten forteller jeg om et mønster som ofte gir meg renere enhetstester..

Her er et eksempel hvor jeg utvikler et lite språk (DSL) for å svare på meldinger. Reply_with_message.sms er et skript skrevet i dette språket som automatisk skal svare med teksten fra den innkommende meldingen flettet inn.

Reply_with_message.sms:

 10 reply "You said: \"${message}\""

For å kontrollere at dette fungerer har jeg laget følgende enhetstest i C#:

 12 [TestScript("Reply_with_message.sms", Sender = "55596698", Text="This is a test")]
 13 public class Spec_reply : CompileAndEvaluateTestFixture
 14 {
 15     [Test]
 16     public void Should_reply_once_and_only_once()
 17     {
 18         Result.Replies.Count.ShouldEqual(1);
 19     }
 20     [Test]
 21     public void Should_reply_with_original_text()
 22     {
 23         Result.Replies.First().ShouldEqual("You said: \"This is a test\"");
 24     }
 25 }

Testklassen inneholder to tester: Den første kontrollerer at det er registrert én utgående melding, og den andre kontrollerer at teksten i meldingen er som forventet.

Testklassen er dekorert med en attributt som spesifiserer navnet på skriptfilen, og informasjon om meldingen den skal behandle. Det er baseklassen som testen arver fra som gjør selve jobben. CompileAndEvaluateTestFixture plukker ut skriptfilen, kompilerer den, og bruker resultatet til å prosessere meldingen jeg spesifiserte. Propertien Result inneholder resultatet.

Dette gir en ren og pen enhetstest, og baseklassen kan gjenbrukes i mange andre tester.

Et komplett eksempel med baseklasse og attributt

Nå har du sett hvor jeg vil hen. La meg ta et annet eksempel, og forklare litt grundigere.

For det første bør jeg nevne hvilke avhengigheter jeg bruker. Hver gang jeg starter et nytt prosjekt kopierer jeg alltid med meg tre ting: Testrammeverket NUnit, mockingrammeverket Moq, og coreTDD for flutende assert (f.eks. ShouldEqual()).

Og så til eksempelet: Her har jeg utviklet en Lexer (også kalt tokenizer eller scanner) som skal ta en kodesnutt og splitte det opp i tokens. Og her er en ren og pen test som eksekverer lexeren på strengen foo = "Some text":

 29 [TokenizeThis("foo = \"Some text\"")]
 30 public class AssignmentTest : BaseLexerTest
 31 {
 32     [Test]
 33     public void Test()
 34     {
 35         ExpectTokens(Tokens.ID, Tokens.ASSIGN, Tokens.STRING);
 36         ExpectTokens("foo", "=", "\"Some text\"");
 37     }
 38 }

Igjen bruker jeg en attributt for å spesifisere dataene for testen, og en baseklasse utfører selve handlingen. Testmetoden kan dermed konsentrere seg om å kontrollere output.

Her er den enkle implementasjonen av attributtet:

177 public class TokenizeThis : Attribute
178 {
179   public TokenizeThis(string input)
180   {
181     Input = input;
182   }
183   public string Input { get; private set; }
184 }

Og her følger baseklassen som gjør hele jobben. I setup-metoden som kjøres før hver test henter jeg først ut input-teksten fra attributtet, oppretter en ny Lexer, og utfører operasjonen. Linje 141-142 er bare debug-output. De to siste metodene er verktøy enhetstestene så kan benytte for å kontrollere resultatet.

126 [TestFixture]
127 public class BaseLexerTest
128 {
129   private Lexer lexer;
130   private TokenizeThis _attributes;
131 
132   public List<Token> tokens { get { return lexer.Tokens; } }
133 
134   [SetUp]
135   public void SetUp()
136   {
137     GetTextToTokenize();
138     lexer = new Lexer(Tokens.All);
139     lexer.Tokenize(_attributes.Input);
140 
141     Console.WriteLine("Tokens for {0}", _attributes.Input);
142     tokens.ForEach(tok => Console.WriteLine(tok));
143   }
144 
145   private void GetTextToTokenize()
146   {
147     try
148     {
149       _attributes = this.GetType()
150         .GetCustomAttributes(typeof(TokenizeThis), false)
151         .First() as TokenizeThis;
152     }
153     catch (Exception ex)
154     {
155       Assert.Fail("Test class {0} does not specify a TokenizeThis attribute!",
156           this.GetType().Name);
157     }
158   }
159 
160   protected void ExpectTokens(params int[] tokenTypes)
161   {
162     tokens.Count.ShouldEqual(tokenTypes.Length);
163 
164     for (int i = 0; i < tokenTypes.Length; i++)
165       tokens[i].Type.ShouldEqual(tokenTypes[i]);
166   }
167 
168   protected void ExpectTokens(params string[] tokenContent)
169   {
170     tokens.Count.ShouldEqual(tokenContent.Length);
171 
172     for (int i = 0; i < tokenContent.Length; i++)
173       tokens[i].Text.ShouldEqual(tokenContent[i]);
174   }
175 }

Jeg har altså laget en attributt som lar meg spesifisere Arrange-delen (eller Given) av testen, og en baseklasse som tar seg av å utføre Arrange og Act (eller Given og When), samt gjøre det enklere for testene å utføre Assert (eller Then).

Denne teknikken gir meg mange flere testklasser, fordi jeg trenger én ny klasse for hver Arrange/Given. Men det resulterer i en mye mere ryddig struktur - det er enkelt å finne frem i testene, og enkelt å se hva testene faktisk tester. Jeg vil også påstå at dette mønsteret gir meg mere stabile tester, med mindre koderepetisjon, og bedre muligheter for refakturering.

Noen flere eksempler

De to foregående eksemplene er klassiske enhetstester, hvor man korrelerer input og output fra en operasjon. Dette kan i teorien settes opp som en tabell, og man kunne kanskje brukt verktøy som Fit/FitNesse til å spesifisere det samme. Ofte bruker jeg i stedet det som akkurat nå kalles for “London school TDD” (er vel i bunn og grunn det vi har kalt BDD i noen år nå), hvor man tester samhandlingen mellom objekter. Da trenger vi mocks/test doubles, og det er da jeg bruker Moq-rammeverket. Denne formen for testing egner seg også godt til den teknikken jeg har beskrevet.

Her er en annen test fra meldings-DSL’en jeg lager. I denne testen setter jeg opp en semantisk modell som er det som er resultatet av at et DSL-skript har blitt kjørt. Jeg skal nå teste at når jeg “utfører” modellen så skal visse ting skje. Helt konkret sier jeg i SetupModel at programmet skal logge to tekstlinjer. Jeg skriver så to testmetoder; den ene kontrollerer at en av tekststrengene ble logget, og den andre tester at log-metoden ble kalt to ganger.

SetupModel er en tom, virtual metode i baseklassen, som vil bli kalt i setup.

 80 public class Executor_must_log : ServiceSemanticsExecutorTestFixture
 81 {
 82     protected override void SetupModel()
 83     {
 84         Model.Log.Add("This should be logged");
 85         Model.Log.Add("This should also be logged");
 86     }
 87 
 88     [Test]
 89     public void Must_log_spesific_log_text()
 90     {
 91         LogMock.Verify(log => log.Log("This should be logged"), Times.Once());
 92     }
 93 
 94     [Test]
 95     public void Must_log_all_items()
 96     {
 97         LogMock.Verify(log => log.Log(It.IsAny<string>()), Times.Exactly(2));
 98     }
 99 }

Det er baseklassen med det herlige navnet ServiceSemanticsExecutorTestFixture som eksekverer modellen, og som har mocks jeg så kan bruke til selve verifiseringen.

Og her følger et siste eksempel som er ganske interessant. Her tester jeg faktisk et testrammeverk! Jeg setter opp to modeller. Den ene jeg kaller Actual er den semantiske modellen fra en DSL. Expectations er den semantiske modellen fra en annen DSL som jeg bruker til å verifisere den første. I testmetoden kan jeg så kontrollere at testrammeverket jeg tester fanger opp missmatch mellom de to modellene, og at det rapporterer denne mismatchen korrekt.

104 public class Spec_ExpectedRepliesVerifier_fail : VerifierTestFixture<ExpectedRepliesVerifier>
105 {
106     protected override void SetupActual()
107     {
108         Actual.Replies.Add("This is a reply");
109     }
110 
111     protected override void SetupExpectations()
112     {
113         Expectations.ExpectedReplies.Add("This is a reply");
114         Expectations.ExpectedReplies.Add("This is another reply");
115     }
116 
117     [Test]
118     public void Should_report_missing_reply()
119     {
120         Result.Ok.ShouldBeFalse();
121         Result.Items.First().Exception.Message
122             .ShouldEqual("Missing reply: \"This is another reply\"");
123     }
124 }

Jeg bruker en ekstra teknikk i denne testen, og det er å bruke Generics til å spesifisere hvilken klasse jeg tester – nemlig ExpectedRepliesVerifier. Baseklassen oppretter en instans av denne, og bruker den til å validere de to modellene mot hverandre. Dermed kan jeg bruke denne samme teknikken på alle verifiseringsobjekter jeg lager, og for alle tenkelige oppsett av modeller.

Les også mine mest populære testrelaterte artikler: Utenfra-og-inn programmering og TDD og mocking i praksis.


comments powered by Disqus