Despite the title of this article which is too dramatic, because probably there is no chaos in the way most of us are writing unit tests, but still I’d like to show a solution which can improve the readability, maintainability and code reusability of unit tests by applying coding styles and design patterns. This is part of a series of articles about how microservice architecture can be applied in a domain centric architecture without constantly dealing with technical aspects, however it is generally applicable to any solution that implements unit tests.
If you haven’t done it yet, I recommend checking my other articles in this series.
Prerequisites: in order to understand the provided examples you should have the basics of unit testing and mocking frameworks.
The source code used in this article can be found in this git repository.
First of all, let’s clarify what unit test is:
A unit test is a way of testing a unit – the smallest piece of code that can be logically isolated in a system. In most programming languages, that is a function, a subroutine, a method or property. A unit can be almost anything you want it to be — a line of code, a method, or a class, generally the smaller the better.
I’d like to emphasize the isolated part from the definition, because that is the key aspect that differentiates unit tests from other automated testing approaches.
So let’s take an imaginary service which needs to be tested:
The above example is a classic one, in the sense that it’s a service called SomeDomainService having two methods DoSomethingAsync and DoSomethingElse and a couple of dependencies injected through the constructor. The first DoSomethingAsync method also calls the second method DoSomethingElse defined in the same service, which we will mock when testing DoSomethingAsync in isolation. Between the dependencies there are other services but also objects that are holding state only.
If you start writing unit tests applying the same principles you do in your application code, at some point you probably end up with something like this:
Above is a test class SomeDomainServiceTests for the SomeDomainService previously seen, which starts with an attempt to extract common logic by defining the mocks at the class level and their setup in the constructor of the test class. I said attempt, because not all the mocks are defined there due to the fact that the Mock<SomeDomainService> is initialized in the test method in order to be able to mock the DoSomethingElse for that specific test only (this is called partial mocking).
Configuring other mocks must also happen before initializing the SomeDomainService, this because configurations on the mock dependencies must be made before their Object property is sent in the constructor of SomeDomainService.
The test itself with the test logic split in Arrange, Act and Assert sections, in order to highlight what type of test logic is there.
If you already have a background with writing unit tests, you probably write tests code in the above way or at least you’ve done it at some point in the past, maybe seasoned with some features offered by speck flow or others.
But these are the issues I see with the above approach:
- The order in which mocks are created and configured with test data and then injected in the tested class matters. Just like in the above example changing the order of any lines in the //Arrange section will result in test failure, so it is quite fragile which can be an issue for more complex tests.
- Extracting the repetitive code is very difficult, due to the fact different test cases have different requirements and the strict order of code execution (see the above point). In the above example this is shown by the need to create someServiceMock in the test method itself, even if it’ll be the same for all the tests. Another disadvantage of extracting repetitive code this way is that Mockbehavior.Strict cannot be used as tests will have different requirements.
- Changing the constructor signature of the tested class you will require to change all its tests, even those that are testing code which isn’t using the added/removed dependency
- A part of the test code is outside of the test method (e.g. common objects, mocks), this means changing shared code (like the one in the constructor) requires to understand the test logic in all of its references and sometimes adjusting them accordingly
- There is no clear separation of concerns between domain logic (specific to a testcase) an test infrastructure (creation of mock and object composition), therefore abstracting repetitive logic such as the one above related to logging can be very difficult often resulting into helper methods that can be used in some tests but in other not.
- Arrange/Act/Assert as a comment, probably many of you won’t agree with me on this, but as a fan of clean coding principles, adding comments to explain what the code does, is a sign of bad design, as the code itself should tell it’s intent not comments. This just reminds me of large code bases where code was organized under #regions so it can be expanded/collapsed.
- Loading the tests with their infrastructure code, is resulting in low signal to noise ratio, just like in the example above, which makes harder for the reader to understand what the test actually does. Just like in the above example if you compare the size of the tested method with the size of the test.
The most suitable solution which I found to address the above issues is implementing the design pattern called Specification, which transforms the above test into this:
Quite some change already, but the more complex the test class is the more significant the difference will be.
First let’s see what the specification design pattern does. This implementation of the Specificatiton pattern, defines a set of business rules with the prefixes Given/When/Then in their name, which can only be used in this order, given the fact that the rules are defined in different interfaces and each rule can return one of these interfaces. Like in the image below:
As you can se different rules will return different interfaces with then will have other set of rules defined. This will ensure that after the WhenExecute rule only Then<> rules will be available.
Compared to other design patterns which deal with business rules, such as visitor, rule engine or pipelines, the specification pattern facilitates a flexible way to orchestrate the execution of these rules, rather than flexible extension of business rules like the other does. And that is exactly what is needed to address the above points.
- Creation of the tested service and its dependencies is done by the Specification object using the same IOC container (IServiceProvider in my case). Before an instance of the tested service is created, the logic from the test framework registers all its constructor parameters as Mock<TDependency> and TDependency in the service collection if not registered previously by any of the Given rules. Therefore changing the dependencies of the tested service will require changing, only the tests actually using it.
- As the specification object abstracts the entire test infrastructure, individual tests only need to define their test data and the orchestration of the Given/When/Then rules, which are specific to the test case. Therefore sharing code between tests is no longer needed, resulting in isolated tests, where test infrastructure code is separated from the code specific to the test case.
- Encapsulating repetitive logic like the one related to logging or IOptions , can be done by implementing new rules exposed by any of the specification interfaces on the Given or Then side. See the rules below:
4. As the Specification framework exposes rules which start with the well known Given/When/Then keywords, it’s no longer needed to type comments to define those sections. Basically every test will start with initializing test data followed by the Given/When/Then specification rules. The specification rules hide a significant part of the test infrastructure logic, increasing the signal to noise ratio of the test code. The remaining code is mostly related to test data initialization and orchestration of the rules which themself can help understanding the code.
As for the performance impact, according to my tests even though it uses Microsoft’s IServiceCollection/IServiceProvider the impact is minor usually varying on the order of execution, for the above examples 86 milliseconds without specification and 93 with specification.
About the gain of productivity that you can expect from this, is depending on how good your tests already are. I can tell you from my experience from different projects, that the estimates given by the teams decreased from 30-35% to 25-30% of the application development effort.
At first look applying the specification pattern in this way may seem to result in a code which is too condensed and harder to read, however after writing a couple of tests I’m pretty sure that you will get used to it realize that, for understanding what the test does, it’s enough to read the rule names and understand the test input data. But I think the biggest gain for readability is that when the specification pattern is applied, all the tests will look pretty much the same, and you’ll spend less time in understanding what the original author of a test wanted to do.