Test-driven development (TDD) is a highly popular and highly misunderstood subject. And there is no surprise as to why it’s so misunderstood.
If you look at any single guide on what the canonical TDD rules are supposed to be, it would be vague and incomplete. Today, I will demonstrate to you why it is so.
However, I will also do something that TDD evangelists may hate me for. I will demonstrate that, even if you follow all TDD rules to the letter, you will, at some point, find that some of these rules contradict each other. Or you will find that some of these rules go against the other best practices of developing software.
Here is a disclaimer before I begin. I am not saying that TDD is bad and that you should abandon it. Not at all. However, I will demonstrate that TDD rules should be treated as guidance and not something set in stone that must be followed at all times.
Different sets of TDD rules
At the most fundamental level, TDD can be described as “Red, Green, Refactor”. These are the names of the stages of TDD. Here is a brief description of what they are:
- Red: You write a test that fails because there is no implementation yet.
- Green: You write the implementation that makes the test pass.
- Refactor: You make your code cleaner, prettier, and more maintainable without any further changes in behavior.
A lot of people who think they know TDD only know these three principles. But these three principles are very vague. If your understanding of TDD is based only on these principles, your understanding is probably wrong.
The problem is that many articles about TDD you can see online are based purely on somebody only having heard of these three principles. This spreads misunderstanding of TDD far and wide and contributes to widespread confusion.
Kent Beck, who is considered to be one of the most authoritative people on TDD, does a better job outlining what canonical TDD rules are. These rules are as follows:
- Write a list of the test scenarios you want to cover
- Turn exactly one item on the list into an actual, concrete, runnable test
- Change the code to make the test (& all previous tests) pass (adding items to the list as you discover them)
- Optionally refactor to improve the implementation design
- Until the list is empty, go back to #2
This provides a much clearer description. However, it’s still somewhat vague. For example, it’s not very obvious that steps 2 and 3 may have to be repeated multiple times for the same test. You may have to write a bit of the test, add a bit of the implementation, add a bit more of the test, add a bit more of the implementation, and so on until both the test and the implementation are ready.
Some of you may ask:
“What are you talking about? You just write the test first and add the implementation later. That’s it.”
Well, Robert Cecil Martin, who is commonly known as Uncle Bob and is considered to be another authority on TDD disagrees with you. His TDD rules are as follows:
- You are not allowed to write any production code unless it is to make a failing unit test pass.
- You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
- You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
So, it’s now clear that you should only carry on writing tests until you can no longer write the said test because the implementation details that will make it compile don’t yet exist. Then you will write just enough implementation to make the test compile. Then you would come back to the test and add some additional bits that you couldn’t add before. The test now compiles but fails. Then you come back to the implementation to make the test pass.
TDD is not about writing tests first and then adding the implementation. It’s about using tests to drive the development. This is why it is called “test-driven development” and not “test-first approach” or something along those lines.
When you look at all these sets of rules in combination, they should give you a sufficient understanding of what TDD is. So now, let’s look at the contradictions you may encounter if you try to follow all these rules to the letter.
Contradictions in TDD
Let’s now try to follow all the canonical rules of TDD in a hypothetical scenario. Let’s do something simple. Imagine that we are building a library from scratch and the first piece of functionality that we are adding to this library calculates the number of whitespace in an input string.
Step 1: test cases
First, we will need to outline the test cases. This exercise allows us to define the base case, multiple variations of a normal usage case, and any edge cases and boundary conditions (e.g. the maximum length of string it can handle).
So far so good. No issues with the TDD process there. This step is extremely useful and we would have probably not done it if all we knew about TDD was “Red, Green, Refactor”.
Once we established the list of test cases, we can start turning them into actual executable tests. Typically, we would start with the base case. In our example, the base case would be that if an empty string is passed into the function or method, the expected output value is 0.
Step 2: turning the base case into a test
Remember the first TDD rule from Uncle Bob that doesn’t allow us to write any production code before we wrote the tests? Well, if we follow this rule, it doesn’t even make sense to create the library yet. We will only create the test library to begin with.
So, we start by writing the test. We define our test method or function (depending on what programming language you are using). Then we define the input parameter, which is an empty string. And now we hit a problem.
The first problem
This might have been OK in the 1990s, but it would cause you inconvenience if done today. You see, if you try to write an invocation of a method that doesn’t yet exist, you will be fighting against your IDE. In some cases, it may not be a problem. Your syntax highlighter would merely highlight it as an error. But in many other cases, the auto-complete feature of your IDE will try to replace this code with something that exists and is spelled vaguely similar.
If you ask me, anything that makes you fight against the fundamental functionality of your IDE is not a good practice.
To be honest, you don’t have to write uncompilable code. You can just stop when you can no longer write any compilable code, add the library in question, add the basic function to it, and come back to your test. From the perspective of basic logic, it makes total sense to do it. There is no disadvantage between doing this and following the second TDD rule of Uncle Bob. You now face the following tradeoff:
- Follow the canonical TDD rule, but experience some inconvenience fighting against your IDE
- Remove the inconvenience of fighting against the IDE, but violate a canonical TDD rule
If you ask me, I would rather do something convenient rather than follow some arbitrary rule. But not following this rule would lead to the second problem.
The second problem
When we need to write the initial implementation of the method, It makes sense to just return the default value of the data type, which is zero.
However, remember that we are dealing with the base case here. If we then add the rest of the test, it will simply pass. This test expects the method to return zero and zero is the value that is being returned. Therefore, by choosing what makes sense and violating a TDD rule that was causing us grief we ended up violating another TDD rule.
We never had a test that was failing. We completely skipped the Red stage.
Of course, we can make this test fail artificially by making the method throw an exception so we can have the Red stage. But this will just add completely unnecessary rework. We need to do things that make sense and we shouldn’t do things just for the sake of ticking some arbitrary boxes.
So, by choosing to stop writing the test at the point where we could no longer write any compilable code we did something that makes total logical sense. We still used tests to drive our code. However, we ended up violating two TDD rules.
This proves that TDD is merely a guide and not a set of rules that must be followed at all times. But if you are not yet convinced, let me talk about other problems that you may face while trying to follow all TDD rules to the letter.
Other problems with TDD
If you try to always follow the three rules of Uncle Bob in all situations, you will soon find that you are shooting yourself in the foot. Here’s why.
Dealing with primary adapters
For example, if you apply the rule of not ever writing production code if tests don’t exist while writing the primary adapter layer, such as the methods mapped to the HTTP endpoint or gRPC services that invoke business logic inside your application, you will find that the tests that you wrote for these endpoints are meaningless at best and harmful at worst.
If you invoke these methods directly from your tests, then you aren’t really testing anything. These methods are never invoked directly by any code, so you are doing something that doesn’t happen in real life.
You can, of course, add tests that call the actual endpoints. However, these would become integration tests, as they would use real networking. These types of tests are out of the scope of TDD, as having many of them will substantially slow down the execution of your test suite. TDD only deals with the so-called “unit tests” (also known as “developer tests”), which invoke the logic directly and are lightning-fast.
In a properly designed application, your business logic will be decoupled from your adapter layer, so why not write the tests against the business logic directly? Your tests will be lightning-fast and you won’t have to duplicate the effort if you decide to apply different kinds of primary adapters to the same business logic so it can be accessed by different types of clients.
Ian Cooper provides an even better explanation of why TDD doesn’t apply to the primary adapters:
Of course, it doesn’t mean that your primary adapters will remain untested. You would still have integration or end-to-end tests in place for those. But those are entirely different types of tests and you can’t use them to drive the development.
Connecting to external apps
Another situation where following these rules is not appropriate is writing clients that connect to external APIs via a network. The code that is actually making these calls is slow, so having these tests inside your test suite will just slow down the whole suite.
Here is another problem. Because it’s a third-party service, it’s not under your direct control, and the sandbox that your tests are connected to might go down. In this case, the tests will be even slower, as they will have to time out while waiting for the response.
It doesn’t make sense to drive the actual implementation of the external clients by tests. Those are better to be mocked in the unit tests.
Of course, this code should still not remain untested. You should still have end-to-end tests that rely on the system making these calls to external services, even if it’s just a sandbox environment. But those are out of the scope of TDD.
Building the UI
A user interface is yet another thing that cannot be written by following the rule of not having any production code before you have a test for it. Yes, you can do TDD in the UI, but its scope should be limited to the logic inside the UI and not the UI layout.
To write a test that invokes some logic by emulating a click on some button, you need to have this button in place first. Yes, once you have this button in the layout, you can drive this logic by writing the test first. But if you try to write the tests before there’s even any layout, you will probably find it unnecessarily hard.
Yes, it’s possible to drive the layout development by writing a test. But it’s much easier to just write the layout first and then about any tests later.
After all, one of the main benefits of TDD is fast feedback. But with the UI, there is even a faster way of receiving the feedback. You can, you know, just look at it. Will be more accurate too, as no automated test can tell you whether your UI looks right.
As engineers, we don’t work in the perfect world. Our job is to turn chaos into order and to do so we have to deal with tradeoffs. One of these tradeoffs is deciding whether to follow the TDD rules to the letter or disregard some of them in some situations.
After all, TDD rules are not something that was developed in the lab and undergone a long and tedious peer review process. It’s a practice that was discovered by accident and found to work by some people. It became popular due to its publicity. At best, it can be described as “the rule of thumb”, which is exactly why it should be treated merely as guidance.
Unfortunately, many TDD evangelists have a problem with this statement. TDD became something akin to a cult. There are people out there who will consider you a heretic who deserves to be burned at the stake if you don’t follow these rules to the letter at all times and don’t always get 100% code coverage from TDD!
I don’t understand why TDD can’t be like design patterns, which hardly anyone views as more than just guidelines. People don’t implement design patterns according to the canonical examples. People just implement the parts that work and ignore the rest. I bet you won’t hear many people saying the following:
“No! You can’t use Builder without the Director class!”
Perhaps, the only rule of TDD that needs to be always applied is outlining all test cases of the behavior you want to change before changing this behavior, a rule you wouldn’t even be aware of if you would have only heard about “Red, Green, Refactor”. It brings many obvious benefits and can’t think of a single disadvantage of doing this.
The rest of the TDD rules can be followed or discarded based on the situation.
You don’t have to write uncompilable code. You can make it compilable first. Doing so won’t have any adverse effect compared to following the canonical rule.
You don’t even have to start with tests. You can write the initial method stubs before starting to write the test. You won’t be following the TDD canon to the letter, but it won’t make much difference.
You may even find that writing the full implementation before writing tests is not a bad thing either as long as you have outlined all your test cases up front and know exactly what base case and edge case are.
Don’t be a member of a cargo cult. Be an engineer.
Don’t follow the rules blindly. Understand why these rules exist and disregard them when they no longer make sense.