The topic described in this article is part of my Unit Testing Pluralsight course.
This post is about pragmatic unit testing: how to get the most out of your unit test suite.
Pragmatic unit testing: black-box vs white-box
Pragmatic unit testing is about investing only in the tests that yield the biggest return on your effort. In the previous posts, we discussed what traits a valuable test possess (high chance of catching a regression, low chance of producing a false positive, fast feedback) and how various styles of unit testing (functional, state verification, collaboration verification) differ in terms of their value proposition.
To get the most of your unit tests, you need to treat the system you are testing in as black-box manner as possible. It will help you avoid coupling tests to the SUT’s implementation details and urge you to find ways to verify the observable state of the system instead. Focusing on the behavior the clients of your code care about generally results in fewer false positives making your test suite more valuable overall.
In practice, viewing the SUT as a black-box means that when it comes to unit testing the domain model, you should forgo verifying collaborations between your domain objects altogether and switch to the first two styles of unit testing instead. (We will talk about unit testing the other parts of your application in the next post.)
You might have heard a guideline saying that the black-box approach is good for end-to-end/integration tests whereas the white-box approach is suitable for unit testing. This is generally not the case. Try to avoid white-box testing completely as it encourages coupling unit tests to the SUT’s implementation details. Adhere to the black-box approach as much as possible on each level.
Note that the black-box vs white-box dichotomy is not a binary choice and you will need to know at least something about the SUT in order to unit test it. However, there’s a big difference between knowing the SUT’s public API and its implementation details. Never unit test against the latter.
Architectural changes: dependencies
The shift from verifying collaborations inside your domain model to the first two (functional and state) styles of unit testing is not as simple as it might seem and often requires architectural changes.
First of all, you need to pay attention to how you work with dependencies in your code.
Dependencies can be divided into two categories: stable and volatile. Volatile dependencies are dependencies that work with the outside world, for example, with the database or an external HTTP service. Stable dependencies, on the other hand, are self-contained and don’t interact with any resources outside the process in which the SUT is executed.
Make sure you separate the code which contains business logic from the code that has volatile dependencies. I wrote about it here but it’s worth repeating: your code should either depend on the outside world, or represent business logic, but never both.
That is why building an isolated and self-contained domain model is important. The onion architecture with the domain model at the center of it is exactly about that:
The inner circle here consists of domain classes which communicate only with each other and don’t refer to the outer layers.
You need to change the way you work with stable dependencies as well. Never substitute them in tests and try to always use real objects. Mocking stable dependencies just doesn’t make any sense because both the mock and the original object have predictable behavior which doesn’t change because of external factors.
A common concern which you can often see raised to oppose this idea is that such tests wouldn’t really be unit tests as they touch several classes at once. A unit test, by this logic, is a test that only exercises the SUT and substitutes all its neighbors out.
That is not the case either. In the original TDD book, the definition Kent Beck gave to Unit Test has nothing to do with the SUT being tested in isolation. Instead, it’s about a test running in isolation from other tests. In other words, a unit test is a test which can be run in parallel with other unit tests because it doesn’t interfere with them through any shared resources such as the database or the file system.
In practice, it means that you shouldn’t substitute your domain classes with test doubles. As long as your domain model is isolated from the outside world, your unit tests can be segregated from each other without any additional help.
An important corollary from this guideline is that extracting an interface out of a domain entity in order to “enable unit testing” is a design smell:
public class Order
public void AddLine(IProduct product, IAddress address)
Such interfaced have a special name – header interfaces. That is interfaces that fully mimic the domain class they are supposed to “abstract”. Interfaces that have the only implementation (test doubles don’t count), don’t really represent abstractions and generally should be avoided.
If you adhere to this guideline, you inevitably find yourself on your way up the functional ladder making your code more and more resembling functional architecture. First, you get rid of volatile dependencies in your domain model and move from examining collaborations to verifying state. After that, you refactor some parts of it and remove side effects thus switching to the functional style of unit testing. Most likely, you will not be able to impose immutability to the whole domain model, but the shift – even a partial one – is still worth it.
Architectural changes: layers of indirection
The second architectural change here is reducing the number of layers of indirection. As I mentioned in the previous post, an excessive number of such layers leads to an architecture which is harder to grasp because of lots of dependencies introduced without necessity. This situation is also known as test-induced design damage:
To avoid it, use as flat class structure as possible. That would allow you to eliminate cyclic dependencies and reduce the complexity of your code base.
When you adhere to this guideline, your typical class diagram starts looking like this:
The domain classes here are isolated from the outside world and interact with the minimum amount of peers all of which are also domain classes. The overall coordination is handled by application services (aka controllers / view models). This way, you are able to achieve a good separation of concerns. Application services know how to communicate with the outside world and how to coordinate the work between domain classes whereas the domain classes contain domain knowledge.
These two architectural changes allow you to unit test the domain model using the first two styles, without falling down to the collaboration verification style. And that, in turn, raises the value of your unit test suite. As a side effect, these guidelines also help you see the bigger picture due to its reduced complexity.
I’ll show a thorough example of implementing these changes in practice in the future articles. In the next post, we’ll talk about unit testing the Application Services layer. I’ll also demonstrate legit examples of using mocks.
Let’s summarize this article with the following:
- Pragmatic unit testing is about choosing to invest in the most valuable unit tests only. For the domain model, that are tests that focus on output and state verification, not collaboration verification.
- Adhering to pragmatic unit testing often requires architectural changes.
- Separate the code that contains domain knowledge from the code that has volatile dependencies.
- Don’t substitute stable dependencies, don’t introduce interfaces for domain classes in order to “enable unit testing”.
- Reduce the number of layers in your architecture. Have a single Application Services layer which talks directly to domain classes.