MockManager in unit tests - a builder pattern used for mocks
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 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( new List<CalculatorToken> { CalculatorToken.Addition, CalculatorToken.From(1), CalculatorToken.From(1) } ); 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.
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:
- instantiate the mock manager of the class you want to test (or write one based on the steps above)
- compose specific scenarios for the test (with auto complete for existing already covered scenario steps)
- execute the method you want to test with test parameters
- 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!
The above is the detailed content of MockManager in unit tests - a builder pattern used for mocks. For more information, please follow other related articles on the PHP Chinese website!

Hot AI Tools

Undresser.AI Undress
AI-powered app for creating realistic nude photos

AI Clothes Remover
Online AI tool for removing clothes from photos.

Undress AI Tool
Undress images for free

Clothoff.io
AI clothes remover

Video Face Swap
Swap faces in any video effortlessly with our completely free AI face swap tool!

Hot Article

Hot Tools

Notepad++7.3.1
Easy-to-use and free code editor

SublimeText3 Chinese version
Chinese version, very easy to use

Zend Studio 13.0.1
Powerful PHP integrated development environment

Dreamweaver CS6
Visual web development tools

SublimeText3 Mac version
God-level code editing software (SublimeText3)

Hot Topics

C language data structure: The data representation of the tree and graph is a hierarchical data structure consisting of nodes. Each node contains a data element and a pointer to its child nodes. The binary tree is a special type of tree. Each node has at most two child nodes. The data represents structTreeNode{intdata;structTreeNode*left;structTreeNode*right;}; Operation creates a tree traversal tree (predecision, in-order, and later order) search tree insertion node deletes node graph is a collection of data structures, where elements are vertices, and they can be connected together through edges with right or unrighted data representing neighbors.

The truth about file operation problems: file opening failed: insufficient permissions, wrong paths, and file occupied. Data writing failed: the buffer is full, the file is not writable, and the disk space is insufficient. Other FAQs: slow file traversal, incorrect text file encoding, and binary file reading errors.

C language functions are the basis for code modularization and program building. They consist of declarations (function headers) and definitions (function bodies). C language uses values to pass parameters by default, but external variables can also be modified using address pass. Functions can have or have no return value, and the return value type must be consistent with the declaration. Function naming should be clear and easy to understand, using camel or underscore nomenclature. Follow the single responsibility principle and keep the function simplicity to improve maintainability and readability.

The C language function name definition includes: return value type, function name, parameter list and function body. Function names should be clear, concise and unified in style to avoid conflicts with keywords. Function names have scopes and can be used after declaration. Function pointers allow functions to be passed or assigned as arguments. Common errors include naming conflicts, mismatch of parameter types, and undeclared functions. Performance optimization focuses on function design and implementation, while clear and easy-to-read code is crucial.

C language functions are reusable code blocks. They receive input, perform operations, and return results, which modularly improves reusability and reduces complexity. The internal mechanism of the function includes parameter passing, function execution, and return values. The entire process involves optimization such as function inline. A good function is written following the principle of single responsibility, small number of parameters, naming specifications, and error handling. Pointers combined with functions can achieve more powerful functions, such as modifying external variable values. Function pointers pass functions as parameters or store addresses, and are used to implement dynamic calls to functions. Understanding function features and techniques is the key to writing efficient, maintainable, and easy to understand C programs.

The calculation of C35 is essentially combinatorial mathematics, representing the number of combinations selected from 3 of 5 elements. The calculation formula is C53 = 5! / (3! * 2!), which can be directly calculated by loops to improve efficiency and avoid overflow. In addition, understanding the nature of combinations and mastering efficient calculation methods is crucial to solving many problems in the fields of probability statistics, cryptography, algorithm design, etc.

Algorithms are the set of instructions to solve problems, and their execution speed and memory usage vary. In programming, many algorithms are based on data search and sorting. This article will introduce several data retrieval and sorting algorithms. Linear search assumes that there is an array [20,500,10,5,100,1,50] and needs to find the number 50. The linear search algorithm checks each element in the array one by one until the target value is found or the complete array is traversed. The algorithm flowchart is as follows: The pseudo-code for linear search is as follows: Check each element: If the target value is found: Return true Return false C language implementation: #include#includeintmain(void){i

The history and evolution of C# and C are unique, and the future prospects are also different. 1.C was invented by BjarneStroustrup in 1983 to introduce object-oriented programming into the C language. Its evolution process includes multiple standardizations, such as C 11 introducing auto keywords and lambda expressions, C 20 introducing concepts and coroutines, and will focus on performance and system-level programming in the future. 2.C# was released by Microsoft in 2000. Combining the advantages of C and Java, its evolution focuses on simplicity and productivity. For example, C#2.0 introduced generics and C#5.0 introduced asynchronous programming, which will focus on developers' productivity and cloud computing in the future.
