The most important TDD rule
In the previous articles, we discussed what causes the pain while writing unit tests (mocks), and how to make TDD painless (get rid of the mocks). Today, I want to set the groundwork and discuss why mocks actually cause so much pain to us, developers.
The most important TDD rule
A quick note before we start: in this series, I refer to both test-first and code-first unit testing approaches as TDD for the sake of brevity. It’s not quite accurate, though, as TDD is about the former, not just writing tests in general. Just keep in mind that I use the TDD term as a synonym to unit testing.
Okay, so what is the most important TDD rule? The rule that entails all the stuff we were talking about in this article series? It’s this: unit tests should operate concepts that are one level of abstraction above the code these unit tests verify. Let’s elaborate on that statement.
Here’s an example of a unit test that relies on mocks:
[Fact]
public void Order_calculates_shipment_amount()
{
var rateCalculator = new Mock<IRateCalculator>();
Order order = new Order(rateCalculator.Object);
order.CalculateShipmentAmount();
rateCalculator.Verify(x => x.Calculate(order));
}
It checks calculation logic by verifying that an Order instance calls the IRateCalculator.Calculate method. We already discussed that such tests are brittle and thus not maintainable. But why is it so?
The reason is that such unit tests check the implementation and not the end result of the system under test (SUT). They go down to how the method is implemented and dictate the right way of doing this. The problem here is that there’s no such thing as the only right way of implementing the calculation logic.
We can do it in many different manners. We could decide to make Order use another calculator or even implement the calculation logic in the Order class itself. All this would result in breaking the test, although the end result could remain exactly the same.
The problem with unit tests using mocks is that they operate on the same level of abstraction as the code of the system under test. That is the reason why they are so fragile and that is the reason why it is hard to build a solid unit test suite with such tests.
The only way to avoid the brittleness is to check the end result of the method, not how this result was achieved. In other words, we need to step away from the actual implementation and rise one step ahead of the code we are unit-testing.
In the example above, we shouldn’t think about how the Order class implements the CalculateShipmentAmount method, all we need to do is verify that the amount matches the expectation.
Not only mocks
Although unit tests using mocks are prone to breaking this rule the most, the rule itself doesn’t refer to the use of mocks specifically. It is possible to write unit tests that don’t rely on mocks and still dictate the system under test how it should implement its behavior.
Here’s an example:
[Fact]
public void GetById_returns_an_order()
{
var repository = new OrderRepository();
repository.GetById(5);
Assert.Equal("SELECT * FROM dbo.[Order] WHERE OrderID = 5", repository.SqlStatement);
}
The unit test above checks that the OrderRepository class prepares a correct SQL statement when fetching an Order instance from the database. The problem with this unit test is the same as with those that use mocks: it verifies the implementation details, not the end result. It insists the SQL statement should be the exact string specified in the test, although there could be myriad different variations of this query leading to the same result.
The solution here is the same as before: the test should operate concepts that reside on a higher level of abstraction. It means we need to verify that the Order instance the repository returns matches our expectations, without paying attention to how the repository does its job.
Shortcomings of the low-level unit tests
Tests that operate on the same level of abstraction as SUT have several drawbacks. Aside from being prone to refactoring, they force programmers do the same work twice, especially when following the test-first approach.
While writing such tests developers are constantly thinking about how the system under test is going to be implemented. They write an expectation of how it should be implemented, then implement it with the exact same code immediately after that.
Low-level tests fully mimic the SUT’s implementation and don’t add any value. While refactoring, you find they break as often as you change any of the implementation details they depend upon, so you need to also apply the same refactoring twice: in the code itself and in the tests covering that code.
Moreover, in many cases, such tests compel you to expose the internal state of the SUT and thus break the encapsulation principles. In the example with OrderRepository, we had to make its SqlStatament property public in order to verify it with the unit test.
It is no coincidence that low-level unit tests lead to poor encapsulation. In order to verify the SUT’s behavior, they need the SUT to expose its internal details to them.
That is actually a good litmus test that can help ensure you follow the "one level of abstraction above" rule while writing unit tests. If the test forces you to expose state that you would keep private otherwise, chances are you are creating a low-level test.
Summary
Low-level tests are the root cause of why TDD can be so painful. The heavy use of such tests is also the underlying reason of why there is such notion as test-induced design damage.
Don’t write tests that operate on the same level of abstraction as the code they cover. It’s hard to overestimate how important that is. This rule is the dividing line between fragile tests and a solid and reliable test suite that helps grow your software project.
If you enjoyed this article, be sure to check out my Pragmatic Unit Testing Pluralsight course too.
Other articles in the series
Subscribe
Comments
comments powered by Disqus