Test-induced design damage or why TDD is so painful

I’m going to write a couple of posts on the topic of TDD. Over the years, I’ve come to some conclusions of how to apply TDD practices and write tests in general that I hope you will find helpful. I’ll try to distil my experience with it to several points which I’ll illustrate with examples.

Test-induced design damage

I’d like to make a quick note before we start. TDD, which stands for Test-Driven Development, is not the same thing as writing unit tests. While TDD implies the latter, it emphasizes test-first approach, in which you write a unit test before the code it tests. In this article, I’m talking about both TDD and writing tests in general. I also refer to both of these notions as "TDD" for the sake of brevity, although it’s not quite accurate.

Have you ever felt like adhering to the TDD practices or even just unit testing your code after you wrote it brings more problems than solves? Did you notice that, in order to make your code testable, you need to mess it up first? And the tests themselves look like a big ball of mud that is hard to understand and maintain?

Don’t worry, you are not alone. There was a whole series of discussions on whether or not TDD is dead, in which Martin Fowler, Kent Beck, and David Heinemeier Hansson tried to express their views and experiences with TDD.

The most interesting takeaway from this discussion is the concept of test-induced design damage introduced by David Hansson. It generally states that you can’t avoid damaging your code when you make it testable.

How is it so? Let’s take an example:

[HttpPost]
public HttpResponseMessage CreateCustomer([FromBody] string name)
{
    Customer customer = new Customer();
    customer.Name = name;
    customer.State = CustomerState.Pending;
 
    var repository = new CustomerRepository();
    repository.Save(customer);
 
    var emailGateway = new EmailGateway();
    emailGateway.SendGreetings(customer);
 
    return Ok();
}

The method is pretty simple and self-describing. At the same time, it’s not testable. You can’t unit-test it because there’s no isolation here. You don’t want your tests to touch database because they’d be too slow, nor you want them to send real emails every time you run the test suite.

In order to test business logic in isolation, you need to inject the dependencies to the class from the outside world:

public class CustomerController : ApiController
{
    private readonlyICustomerRepository_repository;
    private readonlyIEmailGateway_emailGateway;
 
    public CustomerController(ICustomerRepository repository,
        IEmailGateway emailGateway)
    {
        _emailGateway = emailGateway;
        _repository = repository;
    }
 
    [HttpPost]
    public HttpResponseMessage CreateCustomer([FromBody] string name)
    {
        Customer customer = new Customer();
        customer.Name = name;
        customer.State = CustomerState.Pending;
 
        _repository.Save(customer);
        _emailGateway.SendGreetings(customer);
 
        return Ok();
    }
}

Such approach allows us to isolate the method’s business logic from external dependencies and test it appropriately. Here’s a typical unit test aimed to verify the method’s correctness:

[Fact]
public void CreateCustomer_creates_a_customer()
{
    // Arrange
    var repository = new Mock<ICustomerRepository>();
    Customer savedCustomer = null;
    repository
        .Setup(x => x.Save(It.IsAny<Customer>()))
        .Callback((Customer customer) => savedCustomer = customer);
 
    Customer emailedCustomer = null;
    var emailGateway = new Mock<IEmailGateway>();
    emailGateway
        .Setup(foo => foo.SendGreetings(It.IsAny<Customer>()))
        .Callback((Customer customer) => emailedCustomer = customer);
 
    var controller = new CustomerController(repository.Object, emailGateway.Object);
 
    // Act
    HttpResponseMessage message = controller.CreateCustomer("John Doe");
 
    // Assert
    Assert.Equal(HttpStatusCode.OK, message.StatusCode);
    Assert.Equal(savedCustomer, emailedCustomer);
    Assert.Equal("John Doe", savedCustomer.Name);
    Assert.Equal(CustomerState.Pending, savedCustomer.State);
}

Does it seem familiar? I bet you created plenty of those. I did a lot.

Clearly, such tests just don’t feel right. In order to test a simple behavior, you have to create tons of boilerplate code just to isolate that behavior out. Note how big the Arrange section is. It contains 11 rows compared to 5 rows in both Act and Assert sections.

Why TDD is so painful

Such unit tests also break very often without any good reason - you just need to slightly change the signature of one of the interfaces they depend upon.

Do such tests help find regression defects? In some simple cases, yes. But more often than not, they don’t give you enough confidence when refactoring your code base. The reason is that such unit tests report too many false positives. They are too fragile. After a time, developers start ignoring them. It is no wonder; try to keep trust in a boy who cries wolf all the time.

So why exactly does it happen? What makes tests brittle?

The reason is mocks. Test suites with a large number of mocked dependencies require a lot of maintenance. The more dependencies your code has, the more effort it takes to test it and fix the tests as your code base evolves.

Unit-testing doesn’t incur design damage if there are no external dependencies in your code. To illustrate this point, consider the following code sample:

public double Calculate(double x, double y)
{
    return x * x + y * y + Math.Sqrt(Math.Abs(x + y));
}

How easy is it to test it? As easy as this:

[Fact]
public void Calculate_calculates_result()
{
    // Arrange
    double x = 2;
    double y = 2;
    var calculator = new Calculator();
 
    // Act
    double result = calculator.Calculate(x, y);
 
    // Assert
    Assert.Equal(10, result);
}

Or even easier:

[Fact]
public void Calculate_calculates_result()
{
    double result = new Calculator().Calculate(2, 2);
    Assert.Equal(10, result);
}

That brings us to the following conclusion: the notion of test-induced design damage belongs to the necessity of creating mocks. When mocking external dependencies, you inevitably introduce more code, which itself leads to a less maintainable solution. All this results in increasing of maintenance costs, or, simply put, pain for developers.

If you enjoyed this article, be sure to check out my Pragmatic Unit Testing Pluralsight course too.

Summary

Alright, we now know what causes so-called test-induced design damage and pain for us when we do TDD. But how can we mitigate that pain? Is there a way to do this? We’ll talk about it the next post.

Subscribe


I don't post everything on my blog. Don't miss smaller tips and updates. Sign up to my mailing list below.

Comments


comments powered by Disqus