A few years ago I wrote about this, but in less detail. Here is a more refined version of the same idea.

Intro

Unit tests are both boon and bane to developers. They allow quick testing of functionality, readable examples of use, fast experimentation of scenarios for just the components involved. But they also can become messy, need maintenance and update with every code change and, when done lazily, can't hide bugs rather than reveal them.

I think the reason unit testing is so difficult is because it's associated with testing, something other than code writing, and also that unit tests are written in a way opposite to most other code we write.

In this post I will give you a simple pattern of writing unit tests that will enhance all the benefits, while eliminating most of the cognitive dissonance with normal code. Unit tests will remain readable and flexible, while reducing duplicate code and adding no extra dependencies.

How to unit test

But first, let's define a good unit test suite.

To properly test a class, it has to be written in a certain way. In this post we will cover classes using constructor injection for dependencies, which is my recommended way of doing dependency injection.

Then, in order to test it, we need to:

  • cover positive scenarios - when the class does what it's supposed to do, with various combinations of setup and input parameters to cover the whole functionality
  • cover negative scenarios - when the class fails in the correct way when the setup or input parameters are wrong
  • mock all external dependencies
  • keep all of the test setup, action and assertion in the same test (what is normally called the Arrange-Act-Assert structure)

But that's easier said than done, because it also implies:

  • setting up the same dependencies for every test, thus copying and pasting a lot of code
  • setting up very similar scenarios, with just one change between two tests, again repeating a lot of code
  • generalizing and encapsulating nothing, which is what a dev would normally do in all of their code
  • writing a lot of negative cases for few positive cases, which feels like having more testing code than functional code
  • having to update all of these tests for every change to the tested class

Who loves that?

Solution

The solution is to use the builder software pattern to create fluid, flexible and readable tests in the Arrange-Act-Assert structure, while encapsulating setup code in a class complementing the unit test suite for a specific service. I call this the MockManager pattern.

Let's start with a simple example:

// the tested class
public class Calculator
{
    private readonly ITokenParser tokenParser;
    private readonly IMathOperationFactory operationFactory;
    private readonly ICache cache;
    private readonly ILogger logger;

    public Calculator(
        ITokenParser tokenParser,
        IMathOperationFactory operationFactory,
        ICache cache,
        ILogger logger)
    {
        this.tokenParser = tokenParser;
        this.operationFactory = operationFactory;
        this.cache = cache;
        this.logger = logger;
    }

    public int Calculate(string input)
    {
        var result = cache.Get(input);
        if (result.HasValue)
        {
            logger.LogInformation("from cache");
            return result.Value;
        }
        var tokens = tokenParser.Parse(input);
        IOperation operation = null;
        foreach(var token in tokens)
        {
            if (operation is null)
            {
                operation = operationFactory.GetOperation(token.OperationType);
                continue;
            }
            if (result is null)
            {
                result = token.Value;
                continue;
            }
            else
            {
                if (result is null)
                {
                    throw new InvalidOperationException("Could not calculate result");
                }
                result = operation.Execute(result.Value, token.Value);
                operation = null;
            }
        }
        cache.Set(input, result.Value);
        logger.LogInformation("from operation");
        return result.Value;
    }
}

This is a calculator, as is tradition. It receives a string and returns an integer value. It also caches the result for a specific input, and logs some stuff. The actual operations are being abstracted by IMathOperationFactory and the input string is translated into tokens by an ITokenParser. Don't worry, this is not a real class, just an example. Let's look at a "traditional" test:

[TestMethod]
public void Calculate_AdditionWorks()
{
    // Arrange
    var tokenParserMock = new Mock<ITokenParser>();
    tokenParserMock
        .Setup(m => m.Parse(It.IsAny<string>()))
        .Returns(
            new List<CalculatorToken> {
                CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
            }
        );

    var mathOperationFactoryMock = new Mock<IMathOperationFactory>();

    var operationMock = new Mock<IOperation>();
    operationMock
        .Setup(m => m.Execute(1, 1))
        .Returns(2);

    mathOperationFactoryMock
        .Setup(m => m.GetOperation(OperationType.Add))
        .Returns(operationMock.Object);

    var cacheMock = new Mock<ICache>();
    var loggerMock = new Mock<ILogger>();

    var service = new Calculator(
        tokenParserMock.Object,
        mathOperationFactoryMock.Object,
        cacheMock.Object,
        loggerMock.Object);

    // Act
    service.Calculate("");

    //Assert
    mathOperationFactoryMock
        .Verify(m => m.GetOperation(OperationType.Add), Times.Once);
    operationMock
        .Verify(m => m.Execute(1, 1), Times.Once);
}

Let's unpack it a little. We had to declare a mock for every constructor dependency, even if we don't actually care about the logger or the cache, for example. We also had to set up a mock method that returns another mock, in the case of the operation factory.

In this particular test we wrote mostly setup, one line of Act and two lines of Assert. Moreover, if we want to test how the cache works inside the class we would have to copy paste the entire thing and just change the way we setup the cache mock.

And there are the negative tests to consider. I've seen many a negative test doing something like: "setup just what is supposed to fail. test that it fails", which introduces a lot of problems, mainly because it might fail for completely different reasons and most of the time these tests are following the internal implementation of the class rather than its requirements. A proper negative test is actually a fully positive test with just one wrong condition. Not the case here, for simplicity.

So, without further ado, here is the same test, but with a MockManager:

[TestMethod]
public void Calculate_AdditionWorks_MockManager()
{
    // Arrange
    var mockManager = new CalculatorMockManager()
        .WithParsedTokens(new List<CalculatorToken> {
            CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1)
        })
        .WithOperation(OperationType.Add, 1, 1, 2);

    var service = mockManager.GetService();

    // Act
    service.Calculate("");

    //Assert
    mockManager
        .VerifyOperationExecute(OperationType.Add, 1, 1, Times.Once);
}

Unpacking, there is no mention of cache or logger, because we don't need any setup there. Everything is packed and readable. Copy pasting this and changing a few parameters or some lines is no longer ugly. There are three methods executed in Arrange, one in Act and one in Assert. Only the nitty gritty mocking details are abstracted away: there is no mention of the Moq framework here. In fact, this test would look the same regardless of the mocking framework one decides to use.

Let's take a look at the MockManager class. Now this will appear complicated, but remember that we only write this once and use it many times. The whole complexity of the class is there to make unit tests readable by humans, easily to understand, update and maintain.

public class CalculatorMockManager
{
    private readonly Dictionary<OperationType,Mock<IOperation>> operationMocks = new();

    public Mock<ITokenParser> TokenParserMock { get; } = new();
    public Mock<IMathOperationFactory> MathOperationFactoryMock { get; } = new();
    public Mock<ICache> CacheMock { get; } = new();
    public Mock<ILogger> LoggerMock { get; } = new();

    public CalculatorMockManager WithParsedTokens(List<CalculatorToken> tokens)
    {
        TokenParserMock
            .Setup(m => m.Parse(It.IsAny<string>()))
            .Returns(tokens);
        return this;
    }

    public CalculatorMockManager WithOperation(OperationType operationType, int v1, int v2, int result)
    {
        var operationMock = new Mock<IOperation>();
        operationMock
            .Setup(m => m.Execute(v1, v2))
            .Returns(result);

        MathOperationFactoryMock
            .Setup(m => m.GetOperation(operationType))
            .Returns(operationMock.Object);

        operationMocks[operationType] = operationMock;

        return this;
    }

    public Calculator GetService()
    {
        return new Calculator(
                TokenParserMock.Object,
                MathOperationFactoryMock.Object,
                CacheMock.Object,
                LoggerMock.Object
            );
    }

    public CalculatorMockManager VerifyOperationExecute(OperationType operationType, int v1, int v2, Func<Times> times)
    {
        MathOperationFactoryMock
            .Verify(m => m.GetOperation(operationType), Times.AtLeastOnce);
        var operationMock = operationMocks[operationType];
        operationMock
            .Verify(m => m.Execute(v1, v2), times);
        return this;
    }
}

All of the required mocks for the test class are declared as public properties, allowing any customization of a unit test. There is a GetService method, which will always return an instance of the tested class, with all of the dependencies fully mocked. Then there are With* methods which atomically set up various scenarios and always return the mock manager, so that they can be chained. You can also have specific methods for assertion, although in most cases you will be comparing some output with an expected value, so these are here just to abstract away the Verify method of the Moq framework.

A MockManager base class

Mock managers are very useful and make for readable code and nice tests, but they can be tiresome to write. When you want to test hundreds of classes, writing a mock manager for all becomes really annoying. Luckily, you can use a base class that makes this really easy!

So let's rewrite the CalculatorMockManager class with this base class:

public class CalculatorMockManager
    : MockManagerBase<Calculator>
{
    private readonly Dictionary<OperationType, Mock<IOperation>> operationMocks = [];

    public CalculatorMockManager WithParsedTokens(List<CalculatorToken> tokens)
    {
        GetMock<ITokenParser>()
            .Setup(m => m.Parse(It.IsAny<string>()))
            .Returns(tokens);
        return this;
    }

    public CalculatorMockManager WithOperation(OperationType operationType, int v1, int v2, int result)
    {
        var operationMock = new Mock<IOperation>();
        operationMock
            .Setup(m => m.Execute(v1, v2))
            .Returns(result);

        GetMock<IMathOperationFactory>()
            .Setup(m => m.GetOperation(operationType))
            .Returns(operationMock.Object);

        operationMocks[operationType] = operationMock;

        return this;
    }

    public CalculatorMockManager VerifyOperationExecute(OperationType operationType, int v1, int v2, Func<Times> times)
    {
        GetMock<IMathOperationFactory>()
            .Verify(m => m.GetOperation(operationType), Times.AtLeastOnce);
        var operationMock = operationMocks[operationType];
        operationMock
            .Verify(m => m.Execute(v1, v2), times);
        return this;
    }
}

The first thing we notice is that the base class is a generic one, taking as a generic parameter the type of the class we want to test. Then there are no more properties for mocks, the methods setting up mocks use a GetMock<T> method instead. And finally there is no GetService method.

How does it work? Well, when GetService is called, using reflection we find the constructor parameters and find a value to use in them. By default a mock will be generated for each, which then can be accessed with the GetMock<T> method. However, there are two methods that are virtual, allowing to customize the resolution of either the constructor parameter itself or that of its mock. Moreover, you can just decorate a property of the mock manager class with an attribute, and that property value will be used as the constructor parameter of that type.

And if you really liked the idea of Mock properties, then you can define them as read only properties that call GetMock. Here are the base class and the attribute used to decorate properties as constructor parameter providers:

/// <summary>
/// Base class for mock managers
/// </summary>
/// <typeparam name="TSubject"></typeparam>
public abstract class MockManagerBase<TSubject>
    where TSubject : class
{
    protected readonly Dictionary<Type, Mock> mocks = [];
    private TSubject _service;
    private Dictionary<Type, PropertyInfo> _properties;

    public TSubject GetService()
    {
        if (_service is not null) return _service;
        var subjectType = typeof(TSubject);
        var ctors = subjectType.GetConstructors();

        //Currently supports only services with 1 ctor
        var theCtor = ctors.Single();

        var services = new ServiceCollection();
        foreach (var serviceType in theCtor.GetParameters())
        {
            var paramType = serviceType.ParameterType;
            object paramInstance = CreateInstance(paramType);
            services.AddSingleton(paramType, paramInstance);
        }

        var serviceProvider = services.BuildServiceProvider();
        _service = ActivatorUtilities.GetServiceOrCreateInstance<TSubject>(serviceProvider);
        return _service;
    }

    /// <summary>
    /// Override this to have custom values for constructor parameters
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    protected virtual object CreateInstance(Type type)
    {
        var instance = GetFromProperty(type);
        if (instance is null)
        {
            Mock mock = CreateMock(type);
            mocks[type] = mock;
            instance = mock.Object;
        }
        return instance;
    }

    /// <summary>
    /// Override this to have custom Mocks for constructor parameters
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    protected virtual Mock CreateMock(Type type)
    {
        var mockType = typeof(Mock<>).MakeGenericType(type);
        var mock = GetFromProperty(mockType) ?? Activator.CreateInstance(mockType);
        return mock as Mock;
    }

    private object GetFromProperty(Type type)
    {
        _properties ??= this.GetType()
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(prop => prop.GetCustomAttribute<ConstructorParameterProviderAttribute>() is not null)
            .ToDictionary(prop => prop.PropertyType, prop => prop);
        if (!_properties.TryGetValue(type, out PropertyInfo prop)) return null;
        return prop.GetValue(this);
    }

    /// <summary>
    /// Get the mock for type <typeparamref name="T"/>
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <returns></returns>
    public Mock<T> GetMock<T>()
        where T : class
    {
        GetService(); // ensure mocks are created
        return mocks[typeof(T)] as Mock<T>;
    }
}
/// <summary>
/// Mark a property in a <see cref="MockManagerBase{TSubject}"/> as a provider 
/// for a type or the mock of a type used in constructor injection
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class ConstructorParameterProviderAttribute : Attribute {}

The test methods should remain unchanged, unless you need a Mock instance outside the mock manager methods, and then you use GetMock.

Here is a more complex mock manager:

public class ExampleMockManager
    : MockManagerBase<Example>
{
    [ConstructorParameterProvider]
    public ExampleDbContext Db { get; } = new MockDbContext();

    protected override object CreateInstance(Type type)
    {
        if (type == typeof(string))
        {
            return DateOnly.FromDateTime(DateTime.Now).ToString("o");
        }
        return base.CreateInstance(type);
    }

    protected override Mock CreateMock(Type type)
    {
        if (type == typeof(IFancyService))
        {
            var mock = CreateFancyMock<IFancyService>();
            return mock;
        }
        return base.CreateMock(type);
    }
}

public class Example
{
    public Example(
        string name, 
        IFancyService fancyService,
        ExampleDbContext dbContext,
        INormalService normalService)
    {
        // ...
    }
}

In this mock manager, the Db property is used to populate the ExampleDbContext constructor parameter, an override of CreateInstance will generate the string as the date of today and an override of CreateMock will create a different type of mock, but just for IFancyService, INormalService gets the regular mock. This is a contrived example, as the following rewrite does exactly the same thing in less code that is much more readable:

public class ExampleMockManager
    : MockManagerBase<Example>
{
    [ConstructorParameterProvider]
    public ExampleDbContext Db { get; } = new MockDbContext();

    [ConstructorParameterProvider]
    public Mock<IFancyService> FancyMock { get; } = new();

    [ConstructorParameterProvider]
    public string Today => DateOnly.FromDateTime(DateTime.Now).ToString("o");
}

Conclusion

This pattern now aligns test writing with code writing:

  • abstract the things you don't care about in any context
  • write once and use many times
  • humanly readable, self documenting code
  • small methods with low cyclomatic complexity
  • intuitive code writing

Writing a unit test now is trivial and consistent:

  1. instantiate the mock manager of the class you want to test (or write one based on the steps above)
  2. compose specific scenarios for the test (with auto complete for existing already covered scenario steps)
  3. execute the method you want to test with test parameters
  4. check everything is as expected

The abstraction doesn't stop at the mocking framework. The same pattern can be applied in every programming language! The mock manager construct will be very different for TypeScript or JavaScript or something else, but the unit test would pretty much look the same way.

Hope this helps!

Comments

Be the first to post a comment

Post a comment