Rails creator David Heinemeier Hansson (DHH) recently wrote a post denouncing TDD. Cue mass outrage/support from the web. I’m a bit of a TDD convert so I wanted to put my thoughts down on why that was.
DHH seems to conflate unit testing and TDD and ends up hating on them both so I’ll address each topic separately. Sure, the emphasis with TDD is generally on testing the unit because that’s the focus of your short-term efforts but you don’t have to be limited by that – I sometimes use TDD to create system/integration tests before I start writing the underlying units.
Encourages Single Responsibility in your design
If you’re writing your unit tests properly, each unit is tested in isolation from the other units it interacts with. This encourages the developer to design with the Single Responsibility principle in mind – each unit only being responsible for one aspect of the system.
This seems to be DHH’s main argument against TDD, that it somehow leads to an overly complex architecture where you have a huge set of objects just to enable you to test each one in isolation. Whilst I can sympathise with this somewhat I think there’s a tradeoff to be made.
For me, the advantages of having unit-level test coverage generally outweigh the disadvantage of introducing a few extra objects. And in my experience it is just a few; most separation of functionality into units is not merely contrived for the purposes of testing if following the Single Responsibility principle correctly.
Pinpoints a problem’s source
By testing each unit in isolation it’s much easier to pinpoint the source of a problem – you should have a failing test which tells you the rough, if not exact, point in your unit which is misbehaving. With a system-level test you just know what the problem is, not where it resides.
Also if you’re correctly isolating your units then the unit tests you write will be testing your code and your code alone. This means that when a failing unit test occurs, you can rule out any bugs with collaborators (such as framework code).
Makes unit-level refactoring a breeze
Once you’ve got a good level of coverage for a unit’s public interface, you can easily change the underlying implementation of the unit without worrying about affecting other units relying on that interface. If all your unit tests pass, you have a good degree of confidence that your refactor was successful.
Makes for reliably reproducible tests
If you’re isolating your units away from other systems – a database, the filesystem etc. – you can rely on the fact that your test will run the same every time. If you have tests which do interact with an external system then you have to have a way of controlling their state, else you can’t ensure that they won’t give your system under test inconsistent responses. Furthermore, by having control over collaborators you can ensure your unit handles edge-cases such as network timeouts correctly.
This also has the advantage of tests not affecting persistent state systems such as an external API – you don’t want your tests asking MailChimp to send a campaign to your customers every time they run!
Tests actually get written
Let’s face it, despite lots of tooling, literature and evidence that automated testing is a Good Thing™, the web development industry isn’t particularly good at writing tests. I’ve seen tonnes of production code from many different projects that for various reasons didn’t have any automated test coverage whatsoever. If you encourage a test-first mentality it’s harder to let the tests slide.
You get to check your code works before integrating
By writing the tests first, you get to test your code actually works before you even make it interface with anything else. This works both at unit-level and higher levels: I recently used a test-first approach to write integration tests for an API that didn’t yet have a consumer. I was able to write the API to a specification and check it matched that specification without writing the client.
A bit of pragmatism
It’s not a good idea to adopt TDD, blanket mocking or any other technique blindly without analysing why you’re using it and how it affects the code you write. Having a test-first attitude can lead you to create wasteful tests just to ensure every line you code works (something I’ve definitely been guilty of), just as wanting perfectly isolated unit-level coverage can lead to an overly complex system design.
What’s needed with any approach to testing is pragmatism – as Gary Bernhardt says in his reaction piece TDD, Straw Men, and Rhetoric, “TDD is useful and test isolation is useful, but they both involve making trade-offs.” Let’s not discard unit testing or TDD simply because they can be harmful or wasteful when used in extremis.