The purpose of this article is to show why Test Driven Development is an excellent approach to software quality and how to use it. In the video demo, I show how to apply the principles by building an application in C#.
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 several 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.
A test that verifies the requirement that our system can calculate 40 from the string “5 * 8” could look like this:
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
A game of bowling consists of 10 frames, as shown in the image above. In each frame, the player has two throws to knock down 10 pins. The frame’s score is the total number of pins knocked down, plus bonuses for strikes and spares.
The running score for the frames is the bottom row in the image.
The two throws are shown in the second row, the line with the X’s(strikes) and the /’s(spares)
A spare(shown as “/”) happens 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 throw(in the next frame).
For example, in frame 3 above, the first throw hit 9 pins and the second throw hit the last pin, giving a spare.
The frame’s score is 10 (the total number knocked down) plus a bonus of 7 (the number of pins knocked down on the next throw, in frame 4.)
A strike(shown as “x”) happens when the player knocks down all 10 pins on his first throw.
The bonus for a frame with a strike is the value of the next two balls thrown.
In the tenth frame, a player who gets a spare or strike can throw the extra balls to complete it. However, no more than three throws in the tenth frame are allowed.
Building an application using TDD
In this demo I showcase how to apply TDD to build an algorithm that can score a bowling game using the rules above.
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 when we have the ugly solution: “I will get back to refactoring it later.” But because of time pressure, we never get back, leaving it in a bad state.
The missing refactoring creates 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
Most of the time used while programming is spend reading. 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 our confidence in the code deteriorates over time, eventually, the cost of change will start rising quickly.
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.
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. Building from the ground up makes it easy to control dependencies. Since we write the tests before the production code, we create testable code by definition.
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 expressions 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 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.
Remember the “You aren’t going to need it” (YAGNI) principle.
Over-engineering is a risk to look outfor.
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.
What is a mock?
A mock is a fake implementation of an object that we can control. Consider a method that uses a database object that talks to the database. In a test case that tests this method, we need to control the behavior of the database object. We can’t easily simulate a connection timeout or be sure that a key is no already used. Using the real object gives a lot of problems.
Instead, we replace the database object with a fake implementation where we can choose the behavior from the test case. It allows us to test behavior that is not easily accessible, and make sure the tests are more reliable.
One downside is that we must be 100% sure of the semantics of the real object. If we make our mock throw an ConnectionTimeOutException when the real object throws an IOExeception on timeout we have passing tests but failing production code.
An example of a london approach test case, 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.
TDD is a process that allows us to reach a higher bar for our code. As professional software developers, we care about our code. We want code that is flexible, clean, and delivered on time!
My recommendation for getting started is to do a code kata a couple of times using the process. It allows you to feel the process and is good preparation for using it in an existing codebase.
Level up your code newsletter.
Feel confident delivering your next project!
Actionable insights on how to improve your code.
Real-life examples on how to apply SOLID, TDD, Design Patterns. Not just hello world examples.
Being a professional developer is not just about code. I touch on many of the other aspects needed to succeed as a developer.