Enhetstesting av konsoll-applikasjoner


tirsdag 7. juli 2009 Testing / TDD

I artikkelen min on Bellware's NDC workshop inkluderte jeg en enhetstest jeg hadde skrevet for å teste output til System.Console. Her følger jeg opp med å forklare hvordan jeg gjorde det.

I Snookiepoof, hvor testen var hentet fra, ønsker jeg å ha så høy test coverage som mulig. Jeg utviklet nesten alt vha TDD/BDD, men brukegrensesnittet - et tynt lag som presenterte ting til konsollet - hadde jeg ingen tester for. Etterhvert begynte dette å irritere meg, og jeg måtte finne en måte å få dette under test på.

Her er et eksempel på en test jeg ønsket å kjøre:

    1 [Test]

    2 public void Should_output_new_value()

    3 {

    4     Given_a_ConsoleCreditChangedView();

    5     When_credit_changes_to(42);

    6     Output.should_contain("42");

    7 }

Jeg har et ConsoleCreditChangedView, en konkret implementasjon av et abstrakte CreditChangedView, som skriver til konsollet. Når viewet blir bedt om å vise en CreditChange med en bestemt verdi ønsker jeg å sjekke at den bestemte verdien blir skrevet ut til konsollet (linje 6).

Her er resten av detaljene fra testen, men uten å avsløre hvordan jeg får tak i Output (litt tålmodighet):

    9     Action Given_a_ConsoleCreditChangedView = () =>

   10         consoleCreditChangedView = new ConsoleCreditChangedView();       

   11 

   12     Action<int> When_credit_changes_to = (creditValue) =>

   13         consoleCreditChangedView.Show(new CreditChangedEventArgs(creditValue));

   14 

   15     static ConsoleCreditChangedView consoleCreditChangedView;

   16 }

Et naturlig valg hadde vært å abstrahere bort System.Console, og i stedet skrive til et interface, som jeg så kunne mocke bort i enhetstestene. Men jeg hadde brukte Console.WriteLine mange steder, og det ante meg at det fantes en annen løsning.

En rask titt på msdn-dokumentasjonen viste meg at jeg kunne erstatte konsollets default input og output. Det var akkurat det jeg trengte. Under normal kjøring kunne jeg la konsollet være som det er, mens jeg i enhetstestene kunne bytte ut input og output med noe jeg kunne aksessere og manipulere i testkoden.

Etter å ha prøvet og feilet litt kom jeg opp med løsningen nedenfor; én klasse for å "lure ut" output, én tilsvarende klasse for input, og en test fixture som kombinerte de to.

ConsoleTestingFixture

La oss ta en titt på ConsoleOutputFaker først:

    1 internal class ConsoleOutputFaker

    2 {

    3     private StringWriter _stringWriter;

    4 

    5     internal void SwapConsoleOutput()

    6     {

    7         _stringWriter = new StringWriter();

    8         System.Console.SetOut(_stringWriter);

    9     }

   10 

   11     internal string GetOutput()

   12     {

   13         return _stringWriter.ToString();           

   14     }

   15 

   16     internal void SwapBack()

   17     {

   18         RevertBackToOriginalOutput();

   19         DisposeResources();

   20     }

   21 

   22     private void RevertBackToOriginalOutput()

   23     {

   24         var standardOut = new StreamWriter(System.Console.OpenStandardOutput());

   25         standardOut.AutoFlush = true;

   26         System.Console.SetOut(standardOut);

   27     }

   28 

   29     private void DisposeResources()

   30     {

   31         if (_stringWriter != null)

   32         {

   33             _stringWriter.Close();

   34             _stringWriter = null;

   35         }           

   36     }

   37 }

I linje 7 og 8 kan du se hvordan jeg erstatter konsollets normale output med en StringWriter. Når programkoden min så skriver til konsollet så skrives det egentlig til denne StringWriter'en, og jeg kan hente ut og verifisere inneholdet (linje 13).

Resten av klasser er "cleanup" - linje 24 til 26 viser hvordan output settes tilbake til normalen igjen.

Som lovet lagde jeg også en input faker:

    1 internal class ConsoleInputFaker

    2 {

    3     private StringReader _inputReader;

    4 

    5     internal void SendInput(string text)

    6     {

    7         _inputReader = new StringReader(text);

    8         System.Console.SetIn(_inputReader);           

    9     }

   10 

   11     internal void SwapBack()

   12     {

   13         RevertBackToOriginalInput();

   14         DisposeResources();

   15     }

   16 

   17     private static void RevertBackToOriginalInput()

   18     {

   19         var originalInput = System.Console.OpenStandardInput();

   20         var originalReader = new StreamReader(originalInput);

   21         System.Console.SetIn(originalReader);

   22     }

   23 

   24     private void DisposeResources()

   25     {

   26         if (_inputReader != null)

   27         {

   28             _inputReader.Close();

   29             _inputReader = null;

   30         }

   31     }

   32 }

Som du ser er prinsippet det samme. Jeg setter konsollets input til en ny StringReader (linje 7 og 8). StringReader'en inneholder alerede teksten jeg ønsker å sende til konsollet, så nå kan jeg simulere brukerinteraksjon.

Som jeg sa kombinerer jeg så de to klassene i en test fixture:

    1 public class ConsoleTestingFixture

    2 {

    3     private static ConsoleOutputFaker fakeOut;

    4     private static ConsoleInputFaker fakeIn;

    5 

    6     static ConsoleTestingFixture()

    7     {

    8         fakeOut = new ConsoleOutputFaker();

    9         fakeIn = new ConsoleInputFaker();

   10     }

   11 

   12     [SetUp]

   13     public void TestSetUp()

   14     {

   15         fakeOut.SwapConsoleOutput();

   16     }

   17 

   18     protected static string Output

   19     {

   20         get

   21         {

   22             return fakeOut.GetOutput();

   23         }

   24     }

   25 

   26     protected static void SendInput(string text)

   27     {

   28         fakeIn.SendInput(text);

   29     }

   30 

   31     [TearDown]

   32     public void TestTearDown()

   33     {

   34         fakeOut.SwapBack();

   35         fakeIn.SwapBack();

   36     }

   37 }

Fixturen har en SetUp metode og en TearDown metode: Før hver enhetstest erstattes standard output med min fake, og etter hver enhetstest settes normal output og input tilbake til default.

Fixturen tilbyr så en property for å få tilgang til output (linje 18), og en metode for å sende input (linje 26). Test-klasser som arver fra denne fixturen kan bruke SendInput til å simulere at en bruker skriver noe til konsollet, og aksessere Output for å kontrollere hva programmet skriver til brukeren.

Du lurer kanskje på hvorfor jeg har brukt så mye static i test fixturen? Det kommer av måten jeg liker å skrive testklassene mine på for tiden - hvor jeg bruker Actions deklarert som instans-variabler - og da må det de skal ha tilgang til være static.

Her er et eksempel på en test som simulerer input. Denne klassen arver fra ConsoleTestingFixture, og får dermed tilgang til SendInput-metoden.

    1 [Test]

    2 public void Should_be_able_to_go_back_to_game_from_summary()

    3 {

    4     Given_a_known_ViewModel();

    5     When_the_summary_is_shown();

    6     And_the_user_types(back);

    7     The_user_selection_should_be(GameSummaryResult.ResumeCurrentGame);

    8 }

Og detaljene for And_the_user_types:

    1 Action<string> And_the_user_types = (input) =>

    2     SendInput(input);      

Så hvis du føler deg litt retro en dag og får lyst til å lage en konsoll app, så har du nå ingen unnskydning for å ikke skrive tester :)

Her er en annen blogpost om å erstatte Console.Out og Console.In som jeg brukte som referanse når jeg implementerte min fixture. Og her er en fyr som har gått for en løsning med attributter på test-metodene for å spesifisere input og forventet output.

Det kan også være lurt å ta en titt på hva test-rammeverket du bruker har for støtte av konsoll-testing "out of the box". Skriver du mye konsoll-applikasjoner bør du kanskje også se på NConsoler, et open source biblotek for å bygge konsoll apps som også skal ha noe støtte for enhetstester (sies det).

Lurer du forresten på hvor should_contain metoden jeg kalte på Output i linje 6 i den aller første testen kommer fra? For å skrive lesbare tester er det ganske vanlig å erstatte de vanlige kallene til Assert med metodenavn som flyter bedre. Klikk her for å ta en titt på min FluentAsserts.cs som oversetter NUnits mange asserts til noe jeg er mere fornøyd med vha Extension methods. Koden er mer eller mindre rappet fra open source prosjektet coretdd, som også inneholder tilsvarene metoder for xUnit, MbUnit og MsTest.

Knagger: , , ,


comments powered by Disqus