EDIT 1/22/2021: Changed the title a little bit to make sure y’all weren’t getting NFSW ads when you pull this up. Mea culpa:)
A bazillion years ago (2007) I wrote a throwaway post on my old CodeBetter blog titled BDD, TDD, and the other Double D’s. At some point last summer we were having a conversation at an all hands meeting at Calavista where the subjects of Test Driven Development (TDD) and Behavior Driven Development (BDD) came up. We were looking for new content to post on the company website, so I volunteered to modernize my old blog post above to explain the two techniques, the differences between them, and how we utilize both TDD and BDD on our Calavista projects. And I said that I’d have it ready to go in a couple days. Flash forward 6-8 months, and here’s the first part of that blog post, strictly focused on TDD:)
I’ll be mildly editing this and re-publishing in a more “professional” voice for the Calavista blog page soon.
Test Driven Development (TDD) and Behavior Driven Development (BDD) as software techniques have both been around for years, but confusion still abounds in the software industry. In the case of TDD, there’s also been widespread backlash from the very beginning. In this new series of blog posts I want to dive into what both TDD and BDD are, how they’re different (and you may say they aren’t), how we use these techniques on Calavista projects, and some thoughts about making their usage be more successful. Along the way, I’ll also talk about some other complementary “double D” in software development like Domain Driven Development (DDD) and Responsibility Driven Development.
Test Driven Development
Test Driven Development (TDD) is a development practice where developers author code by first describing the intended functionality in small automated tests, then writing the necessary code to make that test pass. TDD came out of the Extreme Programming (XP) process and movement in the late 90’s and early 00’s that sought to maximize rapid feedback mechanisms in the software development process.
As I hinted at in the introduction, the usage and effectiveness of Test Driven Development is extremely controversial. With just a bit of googling you’ll find both passionate advocates and equally passionate detractors. While I will not dispute that some folks will have had negative experiences or impressions of TDD, I still recommend using TDD. Moreover, we use TDD as a standard practice on our Calavista client engagements and I do as well in my personal open source development work.
As many folks have noted over the years, the word “Test” might be an unfortunate term because TDD at heart is a software design technique (BDD was partially a way to adjust the terminology and goals of the earlier TDD to focus more on the underlying goals by moving away from the word “Test”). I would urge you to approach TDD as a way to write better code and also as a way to continue to make your code better over time through refactoring (as I’ll discuss below).
Succeeding in software development is often a matter of having effective feedback mechanisms to let the team know what is and is not working. When used effectively, TDD can be very beneficial inside of a team’s larger software process first as a very rapid feedback cycle. Using TDD, developers continuously flow between testing and coding and get constant feedback about how their code is behaving as they work. It’s always valuable to start any task with the end in mind, and a TDD workflow makes a developer think about what successful completion of any coding task is before they implement that code.
Done well with adequately fine-grained tests, TDD can drastically reduce the amount of time developers have to spend debugging code. So yes, it can be time consuming to write all those unit tests, but spending a lot of time hunting around in a debugger trying to troubleshoot code defects is pretty time consuming as well. In my experience, I’ve been better off writing unit tests against individual bits of a complex feature first before trying to troubleshoot problems in the entire subsystem.
Secondly, TDD is not efficient or effective without the type of code modularity that is also frequently helpful for code maintainability in general. Because of that, TDD is a forcing function to make developers focus and think through the modularity of their code upfront. Code that is modular provides developers more opportunities to constantly shift between writing focused unit tests and the code necessary to make those new tests pass. Code that isn’t modular will be very evident to a developer because it causes significant friction in their TDD workflow. At a bare minimum, adopting TDD should at least spur developers to closely consider decoupling business logic, rules, and workflow from infrastructural concerns like databases or web servers that are intrinsically harder to work with in automated unit tests. More on this in a later post on Domain Driven Development.
Lastly, when combined with the process of refactoring, TDD allows developers to incrementally evolve their code and learn as they go by creating a safety net of quickly running tests that preserve the intended functionality. This is important, because it’s just not always obvious upfront what the best way is to code a feature. Even if you really could code a feature with a perfect structure the first time through, there’s inevitably going to be some kind of requirements change or performance need that sooner or later will force you to change the structure of that “perfect” code.
Even if you do know the “perfect” way to structure the code, maybe you decide to use a simpler, but less performant way to code a feature in order to deliver that all important Minimum Viable Product (MVP) release. In the longer term, you may need to change your system’s original, simple internals to increase the performance and scaleability. Having used TDD upfront, you might be able to do that optimization work with much less risk of introducing regression defects when backed up by the kind of fine-grained automated test coverage that TDD leaves behind. Moreover, the emphasis that TDD forces you to have on code modularity may also be beneficial in code optimization by allowing you to focus on discrete parts of the code.
Too much, or the wrong sort of modularity can of course be a complete disaster for performance, so don’t think that I’m trying to say that modularity is any kind of silver bullet.
As a design technique, TDD is mostly focused on fine grained details of the code and is complementary to other software design tools or techniques. By no means would TDD ever be the only software design technique or tool you’d use on a non-trivial software project. I’ve written a great deal about designing with and for testability over the years myself, but if you’re interested in learning more about strategies for designing testable code, I highly recommend Jim Shore’s Testing without Mocks paper for a good start.
To clear up a common misconception, TDD is a continuous workflow, meaning that developers would be constantly switching between writing a single or just a few tests and writing the “real” code. TDD does not — or at least should not — mean that you have to specify all possible tests first, then write all the code. Combined with refactoring, TDD should help developers learn about and think through the code as they’re writing code.
So now let’s talk about the problems with TDD and the barriers that keep many developers and development teams from adopting or succeeding with TDD:
- There can be a steep learning curve. Unit testing tools aren’t particularly hard to learn, but developers have to be very mindful about how their code is going to be structured and organized to really make TDD work.
- TDD requires a fair amount of discipline in your moment to moment approach, and it’s very easy to lose that under schedule pressure — and developers are pretty much always under some sort of schedule pressure.
- The requirement for modularity in code can be problematic for some otherwise effective developers who aren’t used to coding in a series of discrete steps
- A common trap for development teams is writing the unit tests in such a way that the tests are tightly coupled to the implementation of the code. Unit testing that relies too heavily on mock objects is a common culprit behind this problem. In this all too common case, you’ll hear developers complain that the tests break too easily when they try to change the code. In that case, the tests are possibly doing more harm than good. The followup post on BDD will try to address this issue.
- Some development technologies or languages aren’t conducive to a TDD workflow. I purposely choose programming tools, libraries, and techniques with TDD usage in mind, but we rarely have complete control over our development environment.
You might ask, what about test coverage metrics? I’m personally not that concerned about test coverage numbers, don’t have any magic number you need to hit, and I think it’s very subjective anyway based on what kind of technology or code you’re writing anyway. My main thought about test coverage metrics are only somewhat informative in that the metrics can only tell you when you may have problems, but can never tell you that the actual test coverage is effective in any way. That being said, it’s relatively easy with the current development tooling to collect and publish test coverage metrics in your Continuous Integration builds, so there’s no reason not to track code coverage. In the end I think it’s more important for the development team to internalize the discipline to have effective test coverage on each and every push to source control than it is to have some kind of automated watchdog yelling at them. Lastly, as with all metrics, test coverage numbers are useless if the development team is knowingly gaming the test coverage numbers with worthless tests.
Does TDD have to be practiced in its pure “test first” form? Is it really any better than just writing the tests later? I wouldn’t say that you absolutely have to always do pure TDD. I frequently rough in code first, then when I have a clear idea of what I’m going to do, write the tests immediately after. The issue with a “test after” approach is that the test coverage is rarely as good as you’d get from a test-first approach, and you don’t get as much of the design benefits of TDD. Without some thought about how code is going to be tested upfront, my experience over the years is that you’ll often see much less modularity and worse code structure. For teams new to TDD I’d advise trying to work “pure” test first for awhile, and then start to relax that standard later.
At the end of this, do I still believe in TDD after years of using it and years of development community backlash? I do, yes. My experience has been that code written in a TDD style is generally better structured and the codebase is more likely to be maintainable over time. I’ve also used TDD long enough to be well past the admittedly rough learning curve.
My personal approach has changed quite a bit over the years of course, with the biggest change being much more reliance on intermediate level integration tests and deemphasizing mock or stub objects, but that’s a longer conversation.
In my next post, I’ll finally talk about Behavior Driven Development, how it’s an evolution and I think a complement to TDD, and how we’ve been able to use BDD successfully at Calavista.
6 thoughts on “Re-evaluating the “*DD’s” of Software Development: Test Driven Development”
Thanks for sharing, looking forward to part 2! I would also like to hear about “intermediate level integration tests and deemphasizing mock or stub objects” – we are trying to figure out better ways to do unit testing on code that works heavily with a database.
Thank you for your reply, but oh, man, what you’re asking about is a huge topic. Anything in particular you’re curious about?
As a tester. I know that this can lead to some issues for the dev’s. What I am after is at least one good test case that catches breaking changes – that let’s you focus on getting the expected outcomes and not dozens of tests for the sake of testing – expand that out with a testers help where it makes sense, not because you have to.
TDD has *some* real value for automated regressions, but I’d rarely say that that would be the only thing you do. In all of our Calavista projects we still have the testing pyramid with the TDD tests mostly in the bottom rungs, and suites of automated tests that are more BDD-ish that act as regression tests. And even then, we still have manual testers doing regression tests. Especially when there’s a large UI component.
At no point did I say that TDD is the only possible technique or practice you need to use to succeed on a projection.
You mention the problem of test coverage: this has real implications. I was using a government site only yesterday that has, for years now, exhibited an undesirable behaviour but which never gets amended because it ‘passes all the tests’.
That is the one real limitation of TDD to my mind – the assumption that, because it passes the tests, the code must be doing what is required. In other words you end up coding to the tests, not to the desired functionality.
I’ve also been in a situation where clearly visible errors in code (eg simple typos etc) are not allowed to be corrected until you’ve written a test that fails, and which passes after the typo is changed. In other words, instead of writing a test that describes what the module should be doing and then correcting the code’s logic accordingly (which will obviously find/fix typos etc) you end up writing a test that is designed simply to prove that you’ve fixed the typo that you made.
A perfect example (as so often happens in supposedly ‘agile’ shops) of the process of coding becoming more important than the purpose of the code.
“intermediate level integration tests” – you said it right there at the end of the post. I also find these to be the most meaningful tests. Unit tests alone give a false sense of security as many times modules that work perfectly in isolation do not interact well. Also, in many cases a module is developed and tested by a single person. Nobody cares to look at the internals of that code, and the developer mostly hears “your code does not do what I want” requiring a change in functionality, rather than “your code does what I want, but I found a bug”. In such situations TDD is mostly a hindrance as the tests have to be re-written along with the code.