Writing Readable, Refactor Tolerant Unit Tests
Nearly 10 years ago, I started down the path of Test Driven Development (“TDD”) and following the practice of writing my tests first. Like many people, my first few years of practicing TDD led to tests who tested the behavior of the system under test, but were very fragile when it came to refactoring. Nearly every time I made a change, it required meto rewrite many of the tests. This fragility went against the notion of writing tests since the main purpose for their existence is to ensure that the code keeps the same functionality in spite of changes / refactor. However, I kept on trying new techniques and over time and have built up effective methods for writing these tests. Books such as The Art of Unit Testing and Working Effectively with Legacy Code have been influential in my quest. It wasn’t until I began on my new team at Microsoft that all of these methods converged and solidified into a sound testing technique that I am extremely pleased with.
Over the years, I have built up a set of fundamental ideas on how automated tests should be written. The combination of these can allows for tests that are easier to maintain and read well for new engineers. The basic tenets are:
- When writing Unit Tests, all tests should run in memory only. (i.e. there should be no external dependencies such as databases, file systems, or api calls)
- Tests should invoke the system under test at the top most level only. (such as at the controller level in an ASP.NET Mvc application, command line for console applications, etc)
- Only external dependencies should be mocked / faked (such as databases, message queues, web services)
- Do not mock anything that is not an external dependency.
- The tests should not expose any of the internals of the system under test. Any dependency injection, mocking, setup, etc should be performed in extension methods or the Test Composition Root.
Given these fundamentals, there are many ways to write cohesive and coherent tests that are flexible and can withstand rigorous refactoring of the system under test.
Sample Unit Test
Using the ideas stated above, most of my tests now read like the following:
var root = TestCompositionRoot.Create();
root.WithPerson(firstName: “the-test”, lastName: “writer”);
root.WithPerson(firstName: “the-test”, lastName: “reviewer”);
var controller = root.Get<PersonController>();
var response = controller.Get();
var result = response.CastValue<ICollection<Application.Models.Person>>();
As you can see, the test reads from top to bottom in a fluent manner where an engineer of any level should be able to pull the code down and understand what is being tested. I once heard the phrase that ‘a test should read like a novel and clearly unfold the story as it goes’. This is a great goal and I feel that I am much closer to that than I ever have been before.
Sample Code and Test Design
The sample test above is from a project that I have created on GitHub to showcase these techniques: https://github.com/jrolstad/unit-testing-demo. This is a code repository written in C# for an ASP.NET Core 2.0 project where there is an API layer as the main system under test. The main components are:
System Under Test
- PersonController: API Controller that performs RESTful action on person data. This controller interacts with both a SQL Server database and another web service.
- ValuesController: API Controller that is the standard controller when creating a new ASP.NET Mvc core project
The unit test project is configured with the following components:
- PersonControllerTests and ValuesControllerTests: xUnit test fixtures that contain the actual tests
- TestCompositionRoot: The Test Composition Root is the main entity that abstracts away all the ‘messiness’ in configuring a system for testing. Any setup, instance retrieval, and test data is interacted with through this class only. Only the test composition root and the object under test should be directly referenced in the test. This root only contains the bare minimum necessary; all other domain specific items are located in extension methods. To ensure test isolation, there is a single root per test.
- TestContext: Similar to the ScenarioContext concept in Specflow, this is a stateful class where all data that is either setup or generated by the system under test lives. Any mocks or fakes need to be configured to write to the TestContext so test code coupling is reduced. To ensure test isolation, there is a specific TestContext per TestCompositionRoot.
- Extensions: Extension methods on the TestCompositionRoot. These perform setup on specific classes / types and encapsulate any mock / fake setup. A good example of how stateful mocks can be configured using the TestContext can be seen in IdentityServiceExtensions.
- InMemoryDbContext: Entity Framework database context that inherits from the context in the system under test. Uses the built-in EF InMemoryDatabase provider to allow for nearly all EF functions using the existing bindings. This allows us to perform a simple binding replacement and all database calls are in memory.
- EntityFramework/Extensions: Extension methods for populating data entities for testing.
Using the fundamentals, test writing techniques, and patterns mentioned above I have been able to write easy to read, comprehensive unit tests that I am confident in when they pass or fail. Used correctly, these tools have allowed me to write great tests, and then refactor as needed in the system under test all while never touching the test code. I am sure these practices will evolve as I learn more; looking forward to feedback from others on my approach.