Readplace

This Is The One Thing Nobody Told You About TDD

itnext.io 10 min read
View original
  • current
Summary (TL;DR)
This article continues a TDD tutorial, showing how to add a new interest rate rule for loan amounts above $5000. The author demonstrates a micro-TDD process inside the main TDD loop, where compilation errors (reference errors) drive code development. By isolating old logic and using the known algorithm as a template, they incrementally build the new calculation. The key insight is that TDD helps discover code patterns, not just create tests. The process uses error messages to guide step-by-step implementation, eventually revealing the need to consolidate duplicate code into variables. Tests serve as domain-specific documentation, and unnecessary tests are removed after patterns emerge.

Imagine a blacksmith. They build the sword on fire, shape it with the hammer, and then finish it with the water.

Programming is a craft as much as blacksmithing.

However, instead of using the forge to build a sword through the power of heat, in programming you use your mind to craft the code through the power of failure. Instead of waiting to see the result after you bring the iron back from the cold, you see the result after the failure becomes a success and the test passes.

Last time I wrote the Missing Practical Step-By-Step Test-Driven Development. Since then, I've written how to create a Money model and how nobody crafts the perfect code in the first time.

The first post introduced the story of Jack, a moneylender searching for better ways to calculate the interest of his loans.

It goes like this:

Hi, I'm Jack. My business is to give loans. I can make a small loan to you, but if you do, then I will charge interest for each dollar above a certain threshold.

Can you build something that can do that calculation for me?

$0 to $2000 = No Interest

$2001 to $5000 = 9 cents per dollar

$5001 to $10000 = 14 cents per dollar

$10001+ = 21 cents per dollar

After the first post, Jack ended up with a product that can calculate $0.09 of interest for each dollar above $2000 up to infinity. That was the first boundary of the problem. Now it's time to develop the code for the next boundary. This time, the code needs to calculate $0.14 of interest for each dollar above $5000.

Like before, there’s a repository where you can see one commit per test run. That repository shows the Red/Green/Refactoring steps in each commit.

  • The commit has a 🔴 when it represents the red step.
  • The commit has a green ✅ when it represents the green step.
  • The commit has a 🔨 when it represents the refactoring step.

Let’s begin.

Jack is happy with $0.09 for each dollar above $2000, but he'll be happier if the code can calculate $0.14 for each dollar above $5000.

Let's start with a test for $5000, which is one dollar less than $5001. You should expect this test to pass and the next one to fail.

✅ Add test for $5000.00 loan amount

As expected, the test passes. $5000 loan amount should still use the old rule. According to Jack's specification, the new rule of $0.14 should only apply for loan amounts of $5001 or higher.

🔴 Add test for $5001.00 loan amount

Now the test for $5001 loan amount fails because it calculates $0.09 instead of $0.14. That is intentional. You haven't implemented the rule for the next range. Therefore, you can see the old algorithm still in effect. That test failure drives you to implement the code for the next range.

The screenshot for the test failure of $5001 loan amount. It expects $270.14. Instead, it gets $270.09.

At this point, there are many paths to take to make this test pass. I can think of two:

  1. Evolve the old conditional and find ways to write the minimum amount of code that can make this test go green as soon as possible.
  2. Isolate the old conditional and create a new one to account only for the new rule. The test only passes once the algorithm is complete.

You can argue the first alternative respect the laws of TDD more than the second one. The first alternative doesn’t require you to build the whole algorithm for the test to pass, the second one probably does. Therefore, the first alternative seems more likely to assist you in achieving a good outcome. However, for the sake of experiment, let's try the second approach.

For the second approach, you can isolate the old conditional to only run for loan amounts that are greater than $2000 and less than $5001. This way you can focus solely on the code for loan amounts of $5001 or more.

🔴 Isolate the code for the first range

The test failure changes from calculating $0.09 for each dollar to ignoring all calculations. Therefore, it returns $0.00. That is because you've isolated the previous logic.

The screenshot for the test failure once you isolate the algorithm for the range between $2000 and $5000. It expects $270.14. Instead, it gets $0.00.

Now how would you develop the first condition for loan amounts greater than or equal to $5001? You can probably write something like "if the loan amount is greater than $5001, then start to build the algorithm for the next range."

Don't do that. You're jumping the gun!

Remember we're doing TDD. Therefore, according to the law number 3, "You are not allowed to write any more production code than is sufficient to pass the one failing unit test." The minimum condition you can write is "if the loan amount is exactly $5001, then start to build the algorithm for the next range." Anything more than that is an unnecessary level of transformation to the code that hurts the purpose of what you're trying to achieve.

"Minimum" is subjective. You can argue that returning a static amount of $270.14 is the minimum you can write to make the test pass.

However, if you change the code like that, it won't assist you to discover the patterns that matter. You know you need an algorithm that is similar to the one for the $0.09. Therefore, you can try to build the new requirement using the code of the previous algorithm as a template, only now you don't need to rediscover the calculation again from scratch.

If you start from the return value, the compilation errors may drive you to rebuild the old algorithm in the context of the new requirement.

Let's try.

If you already know the solution for some parts of the problem, you can use the tests to drive you to figure out the remaining parts you don't.

The first thing is to start with the conditional for loan amounts of $5001. Create an undeclared "interest amount" variable and return that.

🔴 Add condition for $5001.00 loan amount

The test is still red because the code didn't declare the variable “interest amount.” However, now it fails with a different error message. The message tells you that something is missing.

The test is asking: How do you calculate the "interest amount"?

According to the previous algorithm, the "interest amount" represents the number of cents multiplied by the dollars above a given threshold.

Let's write that.

🔴 Add correct logic to create variable ‘interest amount’

The test still fails because the code didn’t declare the variable “dollars above threshold,” but again with a different error message.

The test is asking: How do you calculate the dollars above the threshold?

According to the previous algorithm, you calculate the "dollars above threshold" as the difference between the current loan amount, which is $5001, and the value that represents the end of the previous range, which is $5000.

According to the 3rd law of TDD: "you are not allowed to write any more production code than is sufficient to pass the one failing unit test." Therefore, you should not jump and write all the amounts as variables. You're in a process to discover the patterns of the code. If you jump to variables, you'll add significant transformations that make it harder for you to discover those patterns.

🔴 Add correct logic to create variable ‘dollars above threshold’

Now here's the time for the mind-blowing twist. According to the 2nd law of TDD:

You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.

In this context, "Reference Errors" are considered compilation failures. Therefore, in the previous three commits, you applied a Test-Driven Development process INSIDE another Test-Driven Development process without using tests!

WHAT!?! Ok, I'll explain.

In the previous three commits, each compilation failure was a "Reference Error," which represented the red step of TDD. As a counterpart, the successes were the predictable changes in the error messages, the green step. The error messages drove you to transform the code and implement part of the actual algorithm. It's similar to the red/green process of TDD, only that the predictable message changes were a replacement of a real “green” test and there was no refactoring step.

This approach is similar to Outside In TDD, only that instead of focusing on figuring out the connection between modules, you focus on the unknowns between the old algorithm and the new one: the connections in your brain.

Given you already figured out the details of how to calculate cents for each dollar in the first post, you don't need to spend the time to rebuild the math all over again.

You can run a Test-Driven Development process INSIDE another Test-Driven Development process.

At this stage, the error message looks like this:

The test expected $270.14 but got a result of $0.14. Remember you isolated the previous algorithm to only run for loan amounts higher than $2000 and lesser than $5001. The test tells you that you should consider the logic for the previous range too, even for the new requirement. To apply the previous logic, you can copy/paste it and concatenate with the code to calculate the interest for $5001.

🔴 Duplicate the logic for the second range for $5001.00 loan amount

Copy/paste? Hmm… that doesn't sound right.

You need the code for the first algorithm, but you isolated it in the first step. It seems that this is a smell of running with the second approach. The test is driving you to implement too much code, even for the smallest step necessary to make it pass.

Two years ago I wrote about why copy/pasting code has a lower level of retention of knowledge than typing. Typing may force you to understand the details of the code while copy/paste may not. However, there's no silver bullet. Every good practice has exceptions.

If you have written the code recently and understand all the details of it, then it's ok to duplicate. There are cases where the cost to build it again outweigh the learning benefits you'll have.

The question you may ask yourself is: If I don't duplicate the code and type it all over again, am I going to learn something new that I didn't know before?

It's ok to duplicate the code as long as the cost to build it again outweigh the learning benefits you’ll have.

Unfortunately, the last change still didn't make the test green.

The test is still trying to tell you something.

Given you copy/pasted the previous algorithm, the code is trying to calculate $0.09 plus $0.14 for the $1 above the threshold of $5000. The result is $0.23. The fix is to make sure that $5001 only adds up the remaining cents between $0.14 and $0.09, not the sum of them.

✅ Add the right calculation for $5001.00 loan amount

Here it is, the test is green!

You've got the logic for $5001 loan amount correct. Now it's an excellent opportunity to try the Rule Of Threes and see if you can repeat the same pattern for $5002 and $5003 loan amounts.

🔴✅🔨 The diff for the commits that add the test and code for “$5002 and $5003” loan amounts

Once you do that, you'll see that every time you duplicate the code, you only need to change two values to make the next test pass.

Only now you have empirical evidence that those values should become variables.

✅ Change $5002.00 to $5003.00 in the code duplicated

Congratulations! You've found the code pattern for the next range.

The next steps can be a series of refactorings to clear up the duplication, remove all Magic Numbers and separate the new tests in a new block.

The result is a code that adds up $0.09 for every dollar above $2000 and $0.14 for every dollar above $5000. You get a solution that delivers incremental value exactly as the first post did.

The purpose of TDD is not to create tests. It's to serve as a foundation that can assist you to discover patterns in the code and understand better the problem you're trying to solve.

There's one more thing.

The tests also serve as a documentation of your code, better than code comments. Therefore, it makes sense to use the language of the domain instead of the language of the programmer for the description of the tests. This way, if a new programmer comes to the project, they can easily understand the problem and the code because they have clear examples. The code won't miss critical information of the problem it's trying to solve.

🔨 Change the description of the tests to use the language of the domain

Also, you can review the code coverage and adjust accordingly. The value of TDD is not to add 100% code coverage or merely to create tests. It's to have a discipline where you can ask the right questions to provide empirical evidence the code you write understands the boundaries of the problem it's trying to solve.

TDD is about driving you into enlightenment.

Similar tests are useful to help you to find the patterns of the problem. Once the patterns are clear, you're free to lock down the tests into boundaries that are more likely to cause regressions or boundaries that can provide to the future reader of the code better examples of how the system works. In the case of the interest rates for Jack, the boundaries of the problem are the changes of interest rates between $0.00 to $0.09 per dollar and $0.09 to $0.14 per dollar.

🔨 Remove unnecessary tests to leave only essential coverage

Jack is happier, for that he can now calculate loan amounts above $5000. Now you can go home confident that your work created real value. You can be confident this knowledge allowed you to work more efficiently.

In a next post, I’ll evolve this code to test for the subsequent boundaries. You’ll see how you can apply the same techniques to calculate loan amounts with $0.21 of interest rate for every dollar above $10000. Also, I'll refactor the code to remove the duplication.

At that point, perhaps you’ll get to the end of this story.

Stay tuned 📻.