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:
FuncfuncInvalidIsbn = () => { 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.