3 tips for maintainable unit tests
Although having a good collection of unit tests makes you feel safe and free to refactor, a bad collection of tests can make you scared to refactor. How so? A single change to application code can cause a cascade of failing tests. Here are some tips for avoiding (or fighting back) from that situation.
Tip 1: Test behaviour not structure
The behavior of the system is what the business cares about and it is what you should care about as well from a verification point of view. If requirements change drastically then changes to the system are expected, including the tests. The promise of good unit test coverage is that you can refactor with confidence that your tests will catch any regressions in behavior. However if you are testing the structure of your application rather than the behavior, refactoring will be difficult since you want to change the structure of your code but your tests are asserting that structure! Worse, your test suite might not even test the behavior but you have confidence in them because of the sheer volume of structural tests.
If you test the behavior of the system from the outside you are free to change implementation and your tests remain valid. I am not necessarily talking about integration style tests but actual unit tests whose entry point is a natural boundary. At work we have use-case classes that form this natural entry-point into any functionality.
So let’s look at an example of structural testing, and see the what happens when we try to make a change to the implementation details. As an example, we have a test against a
CreatePerson use-case that creates a
Person class and persists it if it is a valid person object. The initial design takes in an
IValidator to determine whether the person is valid.
Notice how we are asserting against a dependency (
IValidator) of the use-case (
CreatePerson). Our test has structural knowledge of how
CreatePerson is implemented. Let’s see what happens when we want to refactor this code…
Your team has been trying to bring in some new practices like Domain-Driven Design. The team discussed it and the
Person class represents an easy start learning. You have been tasked with pulling behavior into the the
Person entity and make it less anemic.
As a first try you move the validation logic into the
Looking at the use-case, we no longer need to inject
IValidator. Not only is what we test going to have to change, we are going to have to change the test completely because we no longer have a validator to inject as a mock. We have seen the first signs of our tests being fragile.
Let’s try make our test focus on the behavior we expect instead of relying on the structure of our code.
Don’t worry too much about
InMemoryPersonRepository people = Given.People; for now, we will come back to it. All you need to know is that
Since we no longer need
IValidator and it’s implementation, we delete those. We also get to delete the test
CreatingPerson_WithValidPerson_CallsIsValid as we have a better test now
CreatePerson_WithValidName_PersistsPerson that asserts the behavior we care about, the use-case creating and persisting a new person. Yay, less test code, better coverage!
At this point you might be saying “Wait! Unit tests are supposed to test one method, on one class”. No! A unit is whatever you need it to be. I am by no means saying write no tests for your small implementation details, just make sure you are comfortable deleting them if things change. With our focus on behavior tests we can delete those detailed tests freely and still be covered. In-fact, I often just delete the tests after I am done developing the component as I just used TDD for fast feedback loop on the design and implementation. Remember that test code is still code that needs maintenance so the more coverage for less the better.
So back to the code. What does our use-case look like now?
Thats ok. We got rid of a dependency and moved some logic to our
Person entity but we can do better. On reviewing your pull request someone in the team pointed out something important. You should be aiming to make unrepresentable states unrepresentable. The business doesn’t allow saving a person without a name so let’s make it so that we can’t create an invalid
Look at that! We refactored the implementation without having to update our test. It still passes without any changes.
This was a contrived example to illustrate the point but I hope this tip helps you write more maintainable tests.
Tip 2: Use in-memory dependencies
You have already seen
InMemoryPersonRepository so this tip should be less verbose to explain. The claim is simply that the maintainability of your tests can be increased by using in-memory versions of your dependencies a little more and using mocking frameworks a little less.
I find in-memory versions of something like a repository that speaks to a database preferable to mocking frameworks for a few reasons:
- They tend to be easier to update than a mocking framework, especially if creation of the mocks is done in every test or fixture
- Coupled with some tooling (see next tip) they lead to far easier setup and readability
- They are simple to understand
- Great debugging tool
On the down side, they do take a little time to create.
Let’s take a quick look at what the one looks like for our code so far:
Super simple! Put in the work and give it a try, it may not be as sexy as a mocking framework but it really will help make your test suite more manageable.
Tip 3: Build up test tooling
Test tooling in this context means utility classes to make readability and maintainability of the tests easier. A big part of this is about making your tests clear about the setup while still keeping it concise.
Let’s discuss a few helpers you should have in any project…
This was already discussed above. I can’t stress enough how much this improves maintenance and simplifies reasoning about tests.
Builders can be used as an easy way to setup test data. They are a great way of simultaneously avoiding dozens of different setup methods for your tests and a way to make it clear what the actual setup of your test is without diving into some setup method that looks like all the other setup methods.
A little trick is to put an
implicit conversion to the class you are building up. Also take a look at Fluency for helping with the creation of builders.
A final note on this point. Just because I use builders a lot does not mean I completely throw mocking frameworks out the window. I just tend to use mocking frameworks for things I really don’t care about and really aren’t likely to change. I also tend to use them within other builders rather than directly in tests. This gives way more control over the grammar that you use to setup your tests.
Not sure what else to call these but it is useful to have a static class that makes access to builders and other types you would use in setup simple. Typically I have
This allows me to write some very concise setup code. For example if I needed to populate my person repository with 3 random people I could do so like this:
For completeness the
So those are my 3 tips for making your tests more maintainable. I encourage you to give them a try. Without investing in the maintainability of your tests they can quickly become a burden rather than a boon. I have seen the practices above improve things not only in my teams but other colleagues have converged on similar learnings with the same positive results. Let me know if you find this helpful, or even if there are any points you strongly disagree with. I would love to discuss in the comments. Happy coding!
If you enjoyed this article you might like some of my others on testing: