Introduction to Unit Tests (with examples in .Net) - Part 2 - Refactoring and Mocking

July 24, 2022

This forms the second in a short series of posts on unit tests. You can find the first post in this series here.

In this post, I’ll be expanding the points raised in the first post to cover a slightly more realistic scenario, and offering some tips of how re-factoring might help with the creation of unit tests. We’ll also cover the basic principles behind mocking - I’m intending to cover this in more detail in a future post of this series.

A Quick Recap

You’re welcome (and encouraged) to go back to the first post in the series; however, to summarise, we discussed the Arrange/Act/Assert pattern, and how it can help us structure a unit test; we spoke about the FIRST principles of testing, and thereby the attributes that we should look for in a good unit test.

What we specifically didn’t cover was any testing frameworks, the concept of mocking or any mocking frameworks, or how to write a unit test in a scenario where you’re not simply adding two numbers together.

A More Realistic Unit Test

If we take an example of any level of complexity, we might question some of the points that were made in the first post. After all, very few methods would simply take two numbers and add them together - or, if they do, perhaps we need to reconsider the language that we’re using.

Let’s look at a simple console application:




int myNumber = Random.Shared.Next(100) + 1;

Console.WriteLine("Guess the number that I'm thinking between 1 - 100");
var guess = Console.ReadLine();
if (string.IsNullOrEmpty(guess))
{
    Console.WriteLine("Invalid guess");
    return;
}

if (int.Parse(guess) == myNumber)
{
    Console.WriteLine("Well done, you guessed!");
}
else
{
    Console.WriteLine($"Sorry, that was the wrong number, I was thinking of {myNumber}");
}


If you had to test this code, how would you do it?

In fact, it’s really difficult, because every time you run it, the number is different. This is a simple piece of code, there’s only 3 code paths; arguably, your strategy could be: run it once and enter a blank value, run it once and enter a non-numeric value, run it 100 more times and hope that you’ll get the number right once.

Writing a Test

As before, let’s start with the manual test that we’ve just described; arguably, we could simply automate this. We’d probably do something like this:



        public static void RunTest()
        {
            // Run method here - check that a blank entry works

            // Run method here - check that a numeric entry works

            for (int i = 0 ; i < 100; i++)
            {
                // Run method here - exit this loop once we've determined that we have a correct and incorrect guess

            }
        }


In fact, running that exact test would be possible - we could simply redirect the console input and output; however, for the purposes of this post, we’ll bypass that method (as it is quite specific to writing a .Net Console app), and we’ll re-factor our code a little.

Refactoring

We can refactor it by splitting the method into two; one method that accepts the input, and one that runs the business logic:



void RunMethod()
{
    int myNumber = Random.Shared.Next(100) + 1;

    Console.WriteLine("Guess the number that I'm thinking between 1 - 100");
    var guess = Console.ReadLine();
    BusinessLogic(myNumber, guess);
}

void BusinessLogic(int myNumber, string guessedNumber)
{
    if (string.IsNullOrEmpty(guessedNumber))
    {
        Console.WriteLine("Invalid guess");
        return;
    }

    if (int.Parse(guessedNumber) == myNumber)
    {
        Console.WriteLine("Well done, you guessed!");
    }
    else
    {
        Console.WriteLine($"Sorry, that was the wrong number, I was thinking of {myNumber}");
    }
}

All we’ve done here is split the method into two methods - the code is exactly the same as it was before. However, now we can run the code in our test without worrying about the input:



        public static void RunTest()
        {
            // Check that a blank entry works
            BusinessLogic(3, "");

            // Check that a non-numeric entry works
            BusinessLogic(3, "aardvark");

            for (int i = 1; i <= 100; i++)
            {
                // Check that false and true numbers work
                BusinessLogic(i, "2");
            }
        }

It still feels a lot like we’re only testing half of the code. We aren’t testing the calling method.

Mocking

Let’s refactor a little further. Instead of using the console, we’ll simply create our own method that performs the same task:



private static string? GetInput() => Console.ReadLine();    

Again, no real change here, we’re just wrapping the code that accepts input in a method that we control.

Now that we’ve done this, we can use our own method to accept input, instead of the Console methods. There’s a number of ways we could do this but, perhaps the easiest, is to pass the GetInput method into the RunMethod method as a parameter:



public static void RunMethod(Func<string> readData)
    {

        int myNumber = Random.Shared.Next(100) + 1;

        Console.WriteLine("Guess the number that I'm thinking between 1 - 100");
        string? guess = readData();
. . .

Here, we’re simply changing two things: we’re accepting a delegate into our main method, and then we’re calling that, instead of the Console.ReadLine().

What’s the point of doing that? Well, now that we control that function as a parameter, we can mock the function, and replace it with our own functionality.

In fact, we are not technically discussing a mock here, but a stub. For the purpose of this post, we’ll simply group them together with the working definition that a mock is: “anything that replaces functionality for the purpose of testing”. I intend to re-visit this in a future post and go into more detail on the difference between the two.

Let’s jump to our test.

Arrange

In the test, we can now replace this functionality with a specific value:



        public static void RunTest()
        {
             // Arrange
            Func<string> mockInput = () => "5"; . . .

We’ve now established the input of the method, the next step is to be able to assert that the test worked. In fact, that’s very difficult with the code as it currently is, because we just display output to the user. To finish this post off, we’ll refactor this as little as we can; imagine we take the business logic function and change it to be like this:



string BusinessLogic(int myNumber, string guessedNumber)
{
    if (string.IsNullOrEmpty(guessedNumber))
    {        
        return "Invalid guess";
    }

    if (int.Parse(guessedNumber) == myNumber)
    {
        return "Well done, you guessed!";
    }
    else
    {
        return $"Sorry, that was the wrong number, I was thinking of {myNumber}";
    }
}

All we’ve changed here is that we’re returning the string, instead of outputting it. We can then change the calling method to do the output:



string RunMethod(Func<string> readData)
{
    int myNumber = Random.Shared.Next(100) + 1;

    Console.WriteLine("Guess the number that I'm thinking between 1 - 100");
    string? guess = readData();
    string result = BusinessLogic(myNumber, guess);
    Console.WriteLine(result);
    return result;
}

Same idea again, a very small change of writing the output, and returning the result again. The functionality hasn’t changed, but now we have something to test against.

Assert

We can now write out test method to look something like this:



for (int i = 1; i <= 100; i++)
{
    // Arrange
    Func<string> mockInput = () => "5";

    // Act
    string result = RunMethod(mockInput);

    // Assert
    if (result == "Well done, you guessed!")
    {
        Console.WriteLine("Test Passed");
        break;
    }
}

for (int i = 1; i <= 100; i++)
{
    // Arrange
    Func<string> mockInput = () => "5";

    // Act
    string result = RunMethod(mockInput);

    // Assert
    if (result.StartsWith("Sorry, that was the wrong number"))
    {
        Console.WriteLine("Test Passed");
        break;
    }
}

{
    // Arrange
    Func<string> mockInput = () => "";

    // Act
    string result = RunMethod(mockInput);

    // Assert
    if (result == "Invalid guess")
    {
        Console.WriteLine("Test Passed");
    }
}

There’s three distinct tests here and, unless unlucky, they’ll all pass. There’s definitely some work left to do here, as we still have the following problems:

1. Although the tests can pass, we have to visually ascertain that.
2. We're outputting to the console needlessly.
3. Our tests are not resilient - if I change a single character in the user output, the tests will break.
4. The tests are not deterministic - they are dependent on the result of a pseudo random number.

In the next post, we’ll address these issues: we’ll introduce a test framework, and further refactor this code such that we can be confident that cosmetic changes will not break the tests.



Profile picture

A blog about one man's journey through code… and some pictures of the Peak District
Twitter

© Paul Michaels 2024