Test-induced design damage or why TDD is so painful

By Vladimir Khorikov

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 readonly ICustomerRepository _repository;

    private readonly IEmailGateway _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.

Other articles in the series

LinkedInRedditTumblrBufferPocketShare




  • cmllamacho

    Great post. Waiting for the second part. I like to develop using TDD but also have found that whenever your code touches external services, the complexity of the tests increase by at least 2x, which I can only assume is the mocks.

    • http://enterprisecraftsmanship.com/ Vladimir Khorikov

      Thanks! Yeah, that’s exactly the situation mocks lead us into.

  • Anders Baumann

    Interesting article. Looking forward to the next post.
    TDD is indeed tricky. It often creates solutions with bad or no design at all. Just making the tests green doesn’t create clean code. I prefer some up-front design and I really like the approach by Ralf Westphal: http://geekswithblogs.net/theArchitectsNapkin/archive/2014/02/12/informed-tdd-ndash-using-mocks-to-allow-for-true-stepwise.aspx

    • http://enterprisecraftsmanship.com/ Vladimir Khorikov

      Thank you! And thanks for the link, I’ll check it out.

  • DAXx85

    Really enjoyed reading this post!
    I like the fluidity of the thought process we are going through and the conclusion pretty much matches my own experience.
    Keep up with the good stuff.

    • http://enterprisecraftsmanship.com/ Vladimir Khorikov

      Thank you for your kind comment!

  • Capde

    Excelent post! Thanks!

    • http://enterprisecraftsmanship.com/ Vladimir Khorikov

      Thanks!

  • youzer

    I’m looking forward to an example using entity framework dbcontext.

  • Guillaume L

    “such unit tests report too many false positives” : you mean false negatives, right ?

    I fail to see your point about test-induced design damage, since in the improved code of your following post entitled How To Do Painless TDD, the design of the production code barely changes. You still have layers of indirection to abstract away persistence and email sending. You still have interfaces at the seams of your application. You still have a Repository and an EmailGateway that are injected through your object’s constructor.

    Yet it’s precisely what DHH blames through the concept of test-induced damage in http://david.heinemeierhansson.com/2014/test-induced-design-damage.html

    I’m quoting him here :

    “Who cares if you need two or three extra layers of indirection to unit test a controller” (he’s being sarcastic there)

    “To achieve this, the simple controller is forbidden from talking
    directly to Active Record, now it has to go through the Repository. And
    the action itself is hollowed out to extract a Command object, which
    then has to call back into the controller through the Listener pattern.

    This is not better.”

    • http://enterprisecraftsmanship.com/ Vladimir Khorikov

      I don’t think eliminating all layers of indirection is a good idea as it usually leads to the God Object anti-pattern. “Flattening” the hierarchy of dependencies and eliminating stable dependencies altogether would be a better design decision. The code in the articles barely changes because it is already flattened in that sense. But I see your point. Indeed, the articles cover only the damage incurred to the test suite, not to the code itself.

      It’s funny you mention the GOOS book as the example the authors go through in it can be considered as code having a damaged design. I have a detailed and long review to this book in mind, hopefully will do it in the near future.