As professional software developers, we care about our code. We want code that is flexible, clean, and delivered on time!
That is the bare minimum we should strive for – yet we often fail to reach that bar. Instead, we end up with buggy and tangled code, delivered late.
Test-Driven Development(TDD) is a discipline that helps us get closer to the bar. It isn’t a silver bullet that fixes all issues we have with code. It doesn’t instantly transform us into professionals. But it gives us a structure to work with that makes it easier.
If you look in the literature, you can find discussions on the details of how and where to apply TDD. The consensus seems to be that testing is vital to create quality code and that TDD is an excellent way to approach it. It is the finer details in TDD that is being discussed.
What is TDD
TDD is a programming discipline “rediscovered” by Kent Bech in 2002 and described in his book Test-Driven Development by example. Because of its age, a lot of research has been done on the practice, it shows that TDD has a significant positive impact on software products’ quality.
TDD works by forcing us to write code in a controlled way. It is an iterative process with a number of steps that we iterate until we have implemented everything we need.
Notice that an iteration usually takes in the order of seconds to minutes. So the tests and production code we write each iteration is tiny. The process forces us to make tiny incremental changes to the code.
The TDD process
We shouldn’t see the process as dogma. The purpose isn’t to follow the process to the letter. Instead, it is a way to reduce the time from writing tests to production code to a few minutes.
That reduction makes it impossible for us to think ”I will get back to it later and clean it up” when we know that it isn’t going to happen. We always have our code in the best possible state.
TDD can’t stand alone. It provides us with a process that forces us to write code in a specific way. But it isn’t an excuse for not thinking. Other useful software practices like design patterns, DRY, SOLID, YAGNI, and others are still essential.
The TDD process goes like this
1. Add a test
We start by writing a test that defines a tiny requirement of the system. It is crucial to notice that we write the test before the production code. It forces us to focus on requirements first, which improves the code’s design.
2. Run all tests and see if the new test fails
To make sure that the test implementation is correct, we run it to see that it fails. The failure shows us two things.
- That the system doesn’t already have the feature, we seek to implement.
- We validate that we didn’t write the test in a way that causes it always to pass.
3. Write the code
Now we switch to the production code and write the code we need to make the test pass. The code doesn’t need to be perfect. It will be improved later. The new code must be the minimum required to have the test pass. That could mean returning a hardcoded value that we know will not be in the final design.
4. Run tests
To make sure that we didn’t introduce any errors, we rerun all tests. If anything fails, we keep changing the code until all tests pass again. This step increases our confidence in the changes we made.
5. Refactor code
From step 3, we might have implemented code that is difficult to follow, bad variable names, duplication, or other harmful code practices. In this step, we clean the code.
We continually run the test suite to make sure the refactoring doesn’t break anything.
Each iteration through this loop should not take more than a couple of minutes.
It is through this tight feedback loop that the magic happens – we end up trusting our code changes. The passing tests tell us that the change didn’t cause any errors. A lot of the magic happens in the refactor step where we force ourselves to improve the code incrementally. It is the continuous refactoring that makes the mess we make manageable.
Test-Driven Development Demo
In this demo, I implement the Bowling game kata. The purpose is to write a program that can calculate the score for a game of bowling—inspired by Robert C. Martin. It uses the classicist TDD approach.
A game of bowling consists of 10 frames as shown above. In each frame the player has two throws to knock down 10 pins. The score for the frame is the total number of pins knocked down, plus bonuses for strikes and spares. The score for the frame is the bottom number. The two throws are shown in the second row with the X’s(strikes) and the /’s(spares)
A spare(shown as “/”) is when the player knocks down all 10 pins in two throws. The bonus for that frame is the number of pins knocked down by the next roll(in the next frame). So in frame 3 above, the score is 10 (the total number knocked down) plus a bonus of 7 (the number of pins knocked down on the next roll.)
A strike(shown as “x”) is when the player knocks down all 10 pins on his first throw. The bonus for that frame is the value of the next two balls rolled.
In the tenth frame a player who rolls a spare or strike is allowed to roll the extra balls to complete the frame. However no more than three balls can be rolled in tenth frame.
In this demo I showcase how to apply TDD to build an algorithm that can calculate the score of a game.
Why is trust in code essential?
We have all had the experience of opening a piece of code – looking at it and thinking. “That’s crap. I need to clean it.” and 2 seconds later, thinking, “I’m not touching that!” because you know that it will probably break, and if you break it, you own it!
It is a symptom of bad code. It slows us down because now we are implementing workarounds to avoid making changes to the original code.
When we are in this situation, the code will continue to deteriorate in quality. Eventually, it grinds development to a halt because nobody dares to change anything, and every new feature is a workaround. And often, we end up with even small changes breaking seemingly unrelated parts of the system.
The problem is that we are afraid of the code. The fear makes the code rot.
If bad code slows us down and we know it. Why do we write it in the first place?
It is impossible to write clean and flexible code in the first iteration – we must first solve the problem, and it will be ugly. It will be ugly because we as humans don’t have the mental bandwidth to focus on solving the issue at hand and making the code clean.
We all know the thought: “we will get back to refactoring it later.” But because of time pressure, we never get back, leaving it in a bad state.
The time pressure causes us to write bad code that slows us down—creating a vicious spiral that ends with all development eventually grinding to a halt.
TDD promises that refactoring becomes part of the process, so we never need to “get back” to clean the code. It is already clean after each iteration.
The benefits of TDD
Writing tests forces us to write twice the amount of code. Will that not be slower?
Most of the time used in programming is not writing code. It is mostly reading code. Having more readable code saves us much more time than we will ever use to write the tests in the first place.
Experience tells us the story shown in the diagram here. If we don’t use TDD, over time the cost of change will increase. Initially we will be able to move fast. But since the trust deteriorates over time, eventually the cost of change will start rising fast.
With TDD the initial cost of change is a bit higher, we need to carry the burden of writing the tests. But soon that effort will pay off and the cost of change will be almost flat.
It is the left part of the diagram before the lines cross that fools us. We think that the cross over will not happen for months so it is not worth pursuing. But experience tells us that the cross over happens in days or weeks, not months or years.
TDD gives us a suite of tests that verify that our change didn’t break anything and that our change is implemented correctly. It gives us confidence in our change; the fear is gone. We dare to refactor the code. We gain control. We speed up.
While confidence and speed is the primary benefit, there are other nice things about TDD.
Code isn’t left in a bad state. Because refactoring is part of the process, we avoid having to “get back to it later,” which we know we can’t.
The code is testable: When writing production code in response to a failing test, it will per definition be testable. Testable code means decoupled code, which is an excellent practice to follow regardless.
The code is cleanable: Because of the confidence the test suite gives us, we dare to refactor the bad code when we find it—creating a good spiral where we keep improving the code.
The code works: According to research, up to 58% of production bugs are caused by trivial code errors. Errors that tests would easily catch.
The code is always known to be working: If a team is practicing TDD, you could pick any team member and know that the code worked just a minute ago. It increases the confidence of the team.
Code examples are created automatically: Tests are code examples for the whole system. It explains what each part of the system does and how it works. It is documentation – it can’t get out of sync. Any developer needing to use a class can read the tests to see all the use cases that the class is designed to handle.
TDD schools of thought
Even though the TDD process is rather strict, it doesn’t define everything. It leaves room for interpretation and has led to two schools of thought.
While both approaches used the same process of writing a test first, seeing it fail, then writing production code to make it pass, they differ on how we approach building the overall system. It leads to very different looking test cases.
The Chicago school is also called classicist or inside-out. As the inside-out name suggests, we build the system from the inside. Focusing on the bottom layers first.
The London school is also called the outside-in approach. As the outside-in name suggests, we build the system from the outside. Focusing on the top layers first.
Chicago school – classicist, inside-out
When building from the inside-out, we start with nothing. That is a good thing since we can design to avoid dependencies.
Each test will use the data-structures from the production code, and avoid depending on objects that are hard to instantiate.
A test case in this school usually follows the same structure, called state verification. It means that we initialize a state, execute a method, and verify the primary object’s state and its collaborators.
In this style, we don’t care what happens inside the system. We only care about setting up the initial state and what the end state is. We are testing the behavior of the system, not the implementation.
Suppose we have a method that evaluates string math expression like “5 * 8”. In the classicist approach we could set up a test like:
We don’t have insight into the implementation inside MathEvaluator. It might delegate evaluation to an advanced parser subsystem, but that’s irrelevant as we are only concerned with the end state.
It might be that the initial implementation is all inside the MathEvaluator class, but at a later time, it is refactored and extracted to a subsystem, which we don’t know and care about. Depending only on the initial and final state allows us complete freedom to refactor the production code without changing the tests.
The classicist approach leads to code that is less coupled to the implementation and is testing behavior instead of implementation. Qualities that we want in our code.
Since we build from the ground up, there should be few cross dependencies to trip us up. The tests give a fine masked safety net that allows us to refactor the code with confidence. Since the tests are independent of the implementation, we should also not see many tests breaking when we refactor our code.
Each test we write is another specification that our production code should live up to. It drives the cycle that we make tests more specific, and the production code is forced to become more generic to pass the tests. This cycle gives a high cohesion, which gives low coupling that is the foundation for quality code.
The drawback of this approach is that when we build from the inside, we run the risk of creating code that is not needed. We need to anticipate the surrounding system’s needs, which can easily lead us to implement stuff that we don’t need. Over-engineering is a risk to look out for. Remember the You aren’t going to need it (YAGNI) principle.
London school – outside-in
When building from the outside-in, we start with a use case. We need an entry point into the system that supports that use case. The test then defines the result we need. Since we don’t have the code on the lower layers, we need to insert mocks to act as stand-ins while building the rest of the system.
An example, consider a method in a controller:
If this is the first code we have in our system, we can’t test it since the “userService” object doesn’t exist. We need to pass a mock that we can control if we want to test this method’s behavior.
The benefit of it is that we focus on building only the parts needed to fulfill the requirements. The downside is that when using mocks, we tie the tests to the implementation.
This approach is sometimes referred to as the mockist approach because it uses mocks extensively.
A test could look like this.
In this example, we inject a mock to the controller to verify that the controller implemented the call to the user service correctly.
When using mocks, we bind the tests to the implementation. It forces us to change the tests if we change the implementation. It leads to fragile tests where small changes might cause us to change many tests.
The upside is that instead of focusing on the smallest parts of the system, we start with a use case and build from its entry points into the system. It forces us to think about how the application will be used and drives only the code needed to support those needs. It ensures that we don’t write dead-code and over-engineer our solution.
Which approach to choose
Both approaches can be used to drive our development. They focus on different aspects that are all important. The best answer is “it depends,” you are probably best off with a mixed approach.
When working on more algorithmic parts of the code, I would go with the classicist approach, but when writing code that is more integration heavy, I tend to use the London approach.
There are ways to always use, for example, the Chicago approach, but it is a tradeoff, as the discussion here shows.
David Heinemeier Hansson on why TDD harms software design: https://dhh.dk/2014/test-induced-design-damage.html
Jim Weirich on how to decouple code from the Ruby on Rails framework using TDD https://www.youtube.com/watch?v=tg5RFeSfBM4
Robert C. Martin contrasts the two approaches https://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html