BitLoop Blog

Adding unit tests to an existing code base

At some point we all probably worked on an inherited code base where maintenance or adding new features can be difficult.

A good approach for this situation is refactoring the code, breaking it into smaller pieces, making it easier to understand and maintain. However, as it is with every refactor, there is the risk of breaking existing functionality.

Mitigating this risk can be done by introducing unit testing. In the situation above, the recommended strategy is to start small, refactor a bit of code, test and then repeat.

In the example snippet below we have code which was refactored into several smaller methods. The original code processed a text file, line by line, and created an order for each line by calling a method, AddBookOrder, which inserts it into to the DB.

/// 
/// Parses a line, creates a BookOrder and adds it to the DB
/// 
/// 
public virtual void ProcessLine(string orderLine)
{
    var cells = orderLine.Split(',');

    var bookOrder = ParseCells(cells);
    AddBookOrder(bookOrder);
}

/// 
/// Validates the cells and creates a new BookOrder if they are valid
/// 
/// 
/// 
public virtual BookOrder ParseCells(string[] cells)
{
    if (cells.Length < 4)
    {
        throw new InputFormatException($"Line doesn't have 4 cells: {string.Join(",", cells)}");
    }

    var orderCode = cells[0];
    var clientId = cells[1];
    var isbn = cells[2];

    if (!IsValidIsbn(isbn))
    {
        throw new InputFormatException($"ISBN {isbn} for order {orderCode} isn't valid");
    }

    if (!int.TryParse(cells[3], out int quantity))
    {
        throw new InputFormatException($"Quantity {quantity} for order {orderCode} isn't a integer");
    }

    if (quantity < 0)
    {
        throw new InputFormatException($"Quantity {quantity} for order {orderCode} must be greater than 0");
    }

    return new BookOrder
    {
        ClientId = clientId,
        Isbn = isbn,
        Quantity = quantity,
        OrderCode = orderCode
    };
}

The first step is to add a new project to the existing solution. The naming convention is to add .Tests to the name of the project we want to test, in this case it would be RefactorOldCode.Tests. The project type should be Class Library and the it should match .NET Framework type(Core or the full framework) and version of the project being tested. To make testing easier it is also recommended to have the Default namespace of the test project the same as project being testes. It can be set in the properties of the project.

Next step is to add the NuGet packages needed: xunit and xunit.runner.visualstudio for running tests, Moq for mocking(will detail this further) and FluentAssertions which improves the assertions for the tests.

The most important thing in our situation is making sure that the methods which were refactored are still working as they should. For this, Moq will be used, even though some argue that mocking should be used only for external dependencies, it is useful for our scenario as well, where we need to test methods in the same class.

In short, Moq enables us to override the implementation of a method, which comes very handy in our scenario or use the original implementation, by using CallBase property.. In order for a method to mocked it is needs to be virtual.

The example below is a good illustration on how to use unit tests with Moq, where mocking of methods or actual implementations is being used.

[Fact]
public void ProcessLineTest()
{
    var mock = new Mock();
    var parsedOrder = new BookOrder 
    { 
        ClientId = "2nw0fs", 
        Isbn = "9871234567890", 
        OrderCode = "1", 
        Quantity = 20 
    };

    // Setup ParseCells so that it always returns the same result when called by ProcessLine
    mock.Setup(m => m.ParseCells(It.IsAny())).Returns(parsedOrder);

    // Setup that ProcessLine is called, not mocked
    mock.Setup(m => m.ProcessLine(It.IsAny())).CallBase();

    var orderProcessor = mock.Object;

    // Call ProcessLine, the input doesn't matter as ParseCells returns the same value, regardless of input
    orderProcessor.ProcessLine(string.Empty);

    // Check if the metod which adds the order DB is called
    mock.Verify(m => m.AddBookOrder(parsedOrder), Times.Once());
}

FluentAssertion comes in handy when we to improve the readability of Assert statements

// Standard Assert
Assert.True(orderProcessor.IsDigitsOnly("12"));

// FluentAssertions Assert
orderProcessor.IsDigitsOnly("12").Should().BeTrue();

or use features which are present in default assertion, like for example asserting the message thrown by an exception:

Func funcInvalidIsbn = () =>
{
    return orderProcessor.ParseCells(new string[]
        {
            "1",
            "123qwe",
            "123456789012a",
            "1"
        });
};

// Check if the error message is for invalid ISBN
funcInvalidIsbn.Should().Throw().WithMessage("ISBN * for order code * isn't valid");

This post is only scratching the surface of unit testing, but hopefully it is a good starting point.