Most developers seem to agree that testing is good, but developers frequently disagree about how to test. In this article, I’ll break down some common misconceptions and hopefully teach you a few things about how you can benefit the most from TDD (Test Driven Development) & unit tests.
1. TDD is too Time Consuming. The Business Team Would Never Approve
This is a common excuse for not testing, and it can really hurt both your development team and the business. Let’s set the record straight.
The business team doesn’t care at all about the development process you use, as long as it’s effective.
What they do care about are business metrics. How does TDD impact business metrics?
- Improve developer productivity (long term)
- Reduce customer abandonment
- Increase the viral factor of your application (i.e., user growth)
- Reduce the costs of customer service
The benefits of TDD have been tested on real projects by companies like Microsoft, IBM, and Springer, and they found that the TDD process is enormously beneficial. Their finding:
Without tests, and even adding tests after you implement, many more bugs get past the development phase, and every bug that gets into production doesn’t simply waste developer time, it hurts the company’s brand and quality reputation. It wastes enormous resources in customer support costs.
Fixing bugs interrupts the normal flow of software development, which causes context switching that can cost up to 20 minutes per bug. That’s 20 minutes where the developer is doing nothing productive — just trying to reboot their brain to figure out the context of the new problem, and then recover the context of the problem they were working on prior to the interruption.
Depending on which study you look at, the TDD process adds 10% — 30% to the initial development costs, but over time, when you factor in the ongoing maintenance and bug fixes, TDD can improve developer productivity, reduce customer abandonment, increase the growth factor of your application, and reduce the costs of customer service.
If you think the business team would resist TDD, you simply haven’t made the case for it with facts.
2. You Can’t Write Tests Until You Know the Design, & You Can’t Know the Design Until You Implement the Code
Let’s clear this up right now. Study after study has concluded that writing tests first is more effective than adding tests later. How much more effective? 40–80% fewer bugs in production more effective.
If you think you’re doing OK charging in with the implementation before you write the test, I’m here to tell you, statistically speaking, you’re giving yourself a major handicap. TDD requires discipline that takes some time to learn.
Developers who have not developed the test-first TDD discipline often charge into the implementation of the code before they know what the API will look like.
They start implementing code before they have even designed a function signature.
This is the opposite of TDD. The point of TDD is that it forces you to have a direction in mind before you start charging into the fray, and having a direction in mind leads to better designs.
Before I even write my first test, I create an RDD doc (RDD stands for Readme Driven Development). I don’t try to flesh out the entire design of the system in RDD form before I start working on the code, but I do decide, I’m building a module that does x, and it needs a function signature that takes y and returns z.
In other words, I make an RDD doc that contains dream code with examples of how a unit’s API will be used. The same kinds of examples you’ll see in software library “Getting Started” and API guides.
For many of my test cases, I simply copy and paste examples from the RDD, which gives me a starting point for simple tests.
For example, a dream code RDD for a number range generator might look like this:
This gives me actual and expected values I can copy and paste right into my unit tests. The first usage above could be repurposed in the following test, and so on:
For more on how to write tests like this, see “Five Questions Every Unit Test Must Answer”.
3. You Have to Write All Tests Before You Start the Code
The reason it’s so hard for developers to imagine TDD working is because software design is an iterative, discovery-driven process. So is building a skyscraper, by the way. Contrary to common belief, architects don’t design the complete skyscraper before any work begins. Crews have to go out and survey the landscape. They have to inspect the ground where the foundation will be built. They have to ensure that the ground can support the weight of the skyscraper. They have to probe beneath the ground to discover whether or not there is a cave system that might collapse, whether there are water problems that need to be worked out and so on.
100% design-up-front is a myth in every type of engineering. Design is exploratory. We try things out, throw them away, try different things until we reach something that we like. Now true, if you wrote every test up front before you wrote a line of implementation code, that would hinder the exploration process, but that’s not how successful TDD works. Instead:
- Write one test
- Watch it fail
- Implement the code
- Watch the test pass
4. Red, Green, and ALWAYS Refactor?
A common response to the instruction list above is “you forgot refactor!”. No, I didn’t. One of the great benefits of TDD is that it can help you refactor when you need to, but I’m gonna level with you: Unless your code is horrendously unreadable, or you’ve benchmarked it and discovered it’s too slow, you probably don’t need to refactor.
“Perfect is the enemy of good.” ~ Voltaire
Sure, look over your code and see if there are opportunities to make it better, but don’t refactor just for the sake of refactoring. Time is wasting. Move on to the next test.
5. Everything Needs Unit Tests
Unit tests work best for pure functions — functions which:
- Given the same input, always return the same output
- Have no side-effects (don’t mutate shared state, save data, talk to the network, draw things to screen, log to the console, etc…)
Unit tests aren’t exclusively for pure functions, but the less your code relies on any shared state or I/O dependencies, the easier it will be to test. Lots of your code won’t be easy to unit test. Lots of your code will talk to the network, query a database, draw to the screen, capture user input, and so on. The code responsible for all of that is impure, and as such, it’s a lot harder to test with unit tests.
People end up mocking database drivers, network I/O, user I/O, and all kinds of other things in an effort to follow the rule that your units need to be tested in isolation.
Here’s a tip that will change your life:
If you have to do a lot of mocking to create a proper unit test, maybe that code doesn’t need unit tests at all.
Maybe a functional test would be a better fit. Trying to use unit tests for I/O dependent code will cause problems, and I estimate that those who complain that test-first is hard are falling into that trap.
Your code should be modular enough that it’s easy to keep I/O dependent modules at the edges of your program, leaving huge parts of the app that can be easily unit tested, but if you feel like you’re forcing modularity just for the sake of testing, and not because it actually makes your app architecture better, you should rethink your testing strategy.
That said, if you’re tempted to do all your testing with functional/e2e tests, that’s problematic, too.
You’re going to end up with:
- Inadequate test coverage which doesn’t properly exercise the modular units of your code, and…
- A tightly-coupled monolith which becomes harder to maintain over time.
Very small projects can get away with both, but really successful projects tend to grow out of that phase, and would benefit from more modular architecture, and better testing discipline.
Healthy test suites will recognize that there are three major types of software tests that all play a role, and your test coverage will create a balance between them.
He spends most of his time in the San Francisco Bay Area with the most beautiful woman in the world.