Monday, February 6, 2012

How to Write Automated Tests for Console Applications

command_line_interfaceI don’t know about the rest of you, but I still find myself writing a lot of console applications. Most of the time these are utilities or services or some kind of customization or process automation scripts. Until recently, if I wanted to write automated tests for these, I would have to separate the business logic from the program itself and test the functions. While this is obviously a good practice for any moderately complex project, it sometimes might be overly complicated for what is needed. Moreover, this doesn’t work for black-box user acceptance tests.

The System.Console Class


In the .NET Framework console apps revolve around, well, the Console class. You get user input with Console.Read() (and ReadLine(), and ReadKey(), and so on). Conversely, you send output to the user with Console.Write() and Console.WriteLine().
So let’s take a look at the code inside the .NET Framework. By the way, I used Telerik’s JustDecompile to browse the code. If you don’t already have a decompiler of choice, I suggest you take a look at it. It’s simple, elegant and free (and no, I don’t own stock).

So, here's the code behind Console.ReadLine():
   1: public static string ReadLine()
   2: {
   3:     return Console.In.ReadLine();
   4: }

And here’s Console.WriteLine():

   1: public static void WriteLine()
   2: {
   3:     Console.Out.WriteLine();
   4: }

As it turns out, Console’s read and write methods are merely pass-through methods that call the same methods on the In and Out TextReader and TextWriter properties (again - respectively).

The text reader and writer classes, as so eloquently put in the class descriptions on MSDN, “represent a reader [and writer] that can read [and write] a sequential series of characters”. Well, that still is more helpful than some descriptions I’ve seen... Anyway, they are the abstract parents of the string and stream reader (and writer) classes. Why is this important? Well, the In and Out properties are by default set to the keyboard input and screen output streams.

Now, if only there was a way to redirect those streams…

Enter Console.SetIn() and Console.SetOut() respectively. Hmm, that’s a lot more respect than I usually show in a blog post…

With SetIn, I can redirect the In text-reader to a StringReader, and with SetOut, I redirect the output to a StringWriter.

I can now, quite easily initialize the string reader to a value that contains whichever input, or set of inputs I want to use, and the console application will read characters or lines as needed. I can then read the output of the string writer, and compare it with expected results.

And the most beautiful part of it is that there is (almost) no need to modify the console application in any way for this to work.

Example


The following example was written in C#, using MS-Test (forgive me) to test the code. The application is a simple one that plays the game 7-Boom (a local variation of the game BizzBuzz). As you will see, except for making the Program class and Main() method public in lines 1 and 3, so that I can reach the code from another (test) assembly, I did nothing I wouldn’t do in any other console application:

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {
   5:         Console.Write("Enter upper limit: ");
   6:         var range = int.Parse(Console.ReadLine());
   7:  
   8:         for (var i = 0; i < range; i++)
   9:         {
  10:             var num = ((i % 7 == 0) || i.ToString().Contains('7')) ? "BOOM" : i.ToString();
  11:             Console.Write("{0}, ", num);
  12:         }
  13:     }
  14: }

And here is a test:

   1: // Arrange
   2: using (var sw = new StringWriter())
   3: {
   4:     using (var sr = new StringReader("100"))
   5:     {
   6:         Console.SetOut(sw);
   7:         Console.SetIn(sr);
   8:  
   9:         // Act
  10:         SevenBoom.Program.Main();
  11:  
  12:         // Assert
  13:         var result = sw.ToString();
  14:         Assert.IsFalse(result.Contains('7'));
  15:     }
  16: }

That’s it! I set the input that the user would have entered in line #7, and redirect the I/O in lines 6-7. All that remains is to assert that the output in line #14 meets the expectations.

By the way, this works exactly the same in Java as it does in C#. The difference is that in Java you will use System.setIn() and System.setOut() to set the PrintStreams (the In and Out properties used in Console.Read & Console.Write).

So now you can write test-driven (and behavior driven) console applications with greater ease.

Hope this helps,

Assaf.

Technorati Tags: ,,,,,,

6 comments:

  1. Amazing!

    What a simple and easy way to test third party console applications we are sometimes forced to use.

    Thanks :-)

    ReplyDelete
  2. Hi Assaf,

    Awesome blog! Is there an email address I can contact you in private?

    ReplyDelete
    Replies
    1. Sure. You can email me at assaf(at)softwareandi.com

      Delete
  3. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. I see the problem. Console.SetIn() sets the console's input stream. Your code doesn't aggregate them, it overwrites the frist three string-readers. That is why only the last one counts. Also, your switched the positions of the parameters in your Assert.AreEquals() method call. The signature is Assert.AreEqual(string EXPECTED, string ACTUAL) not the other way around.

      In order to simulate multiple inputs, rather than using multiple string readers, use one with all four input strings concatenated, with line-feeds ( \n ) separating each line.

      Your code might look like this:
      using (var inputs = new StringReader("77777\nUbinKim\n3000.75\n11122013"))
      {
      Console.SetIn(inputs);

      bool isCreatedAccount = Manager.CreateAccount();

      Assert.AreEqual(false, Manager.AccountsAvailable[3]);
      Assert.AreEqual(77777, Manager.Accounts[3].AccountNumber);
      }

      Hope this helps.

      Delete
  4. Really nice post.

    ReplyDelete

Note: Only a member of this blog may post a comment.