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.
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.
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.
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.