The Missing Practical Step-By-Step TDD 🔊
So you have a fixed specification and deadline? Sorry, that's not an excuse
A lot of people say TDD is useful. However, it's not rare to encounter a developer who says they have been doing TDD for a long time, only to find out they have been doing something completely different.
It's like Wittgenstein's Beetle. If everyone has a black box and they call the content of that box a "beetle," then it's normal for two people to talk about the same thing in general terms, like "TDD is good" or "TDD is bad." However, it's also very likely each one of them has a different perspective and understanding of the subject they're talking about.
This is a post I wrote as an attempt to show, in practice, better ways to find the solution to a problem. It's a way to make sure you understand what TDD means, or at least what it means to me.
I'll try to follow the three great rules:
1. You are not allowed to write any production code unless it is to make a failing unit test pass.
2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
Test-Driven Development is subjective. Each person has a different understanding of how to apply it.
Alright, so what's the problem we're going to solve?
Let's ask the infamous Jack:
Hi nerd, I'm Jack, The Moneylender.
My business is to give loans. For example, I can make a small loan to you but don't try to get too much. If you do, then I will charge interest for each dollar above a certain threshold.
However, it's hard for me to calculate that. Build something that can do it for me, alright? That's what you're paid for, right?
Oh, an I almost forgot: I need that for Friday.
$0 to $2000 = No Interest
$2001 to $5000 = 9 cents per dollar
$5001 to $10000 = 14 cents per dollar
$10001+ = 21 cents per dollar
This story relates very well to small problems you might encounter in real life, even the "Friday thing."
Given the characteristics of the problem for "Jack, The Moneylender," this post is going to use the London school of TDD, also known as Inside Out Test-Driven Development.
There's a repository where you can see one commit per test run. That repository shows the Red/Green/Refactoring steps.
- 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.
This post follows the same principle as Code Review and Test-First Development. The difference is that I've split the commits to expose my thoughts around Test-Driven Development, not Test-First.
Jack is a Moneylender, and he's also an a##hole.
First thing is to set up the coding environment with a green test to make sure all dependencies are working fine. My preference is to make something simple, without npm or any other dependency besides the test library.
Let's get back to our roots and start it with QUnit:
In the assertions, I want to avoid the Parameter Trap from QUnit's API which exists on the "actual" and "expected" arguments. Therefore, I create another function with named arguments that can receive QUnit's "assert" object.
Now let's look at the problem. There are some critical boundaries for this: 0 to 2k, 2k to 5k, 5k to 10k and 10k+.
Let's start with something simple. I'll focus to have only one assertion that may drive me to increase the minimum level of transformation of the code, nothing else.
The most straightforward test I can think of is a loan amount of zero dollars. However, that doesn't seem like a reasonable business case. If Jack wants to calculate a loan amount of zero, that means he's not giving a loan to anybody. Therefore, he won't see value in the code I write.
Just thinking about the first test drove me to ask Jack about this particular case:
A Loan of zero? Come on, that's ridiculous!
Oh, wait, I did send you zero dollars in the specification… ok.
Glad to see you've uncovered a flaw in my logic. I didn't know you were capable of doing that!
— Jack, the ass… err… The Moneylender
I initialize the variable “interest to pay” as “undefined” to have a default “empty” result and a test failure. The expected result, assuming that Jack is giving a loan of $1, is zero.
Therefore, the test fails, because the variable is "undefined."
For the purpose of this post, let's imagine the server represents the code that produces something consumable for the client. The client consumes whatever the server produces.
In the context of the test above, the server is something that calculates the result for variable "interest to pay." The server is not explicitly modeled at this stage. The client is the test code. The test code reads the variable "interest to pay" and pass it to the assertion function.
The most straightforward change I can make in the server that can drive me to make the test to pass is to return $0 to the variable "interest to pay."
For the next test, let's code the behavior for a loan amount of $2. Given I've not modeled the server before, now it's an excellent opportunity to do it. The next test assumes a function with the name "interest to pay for dollars."
Oh, the test throws a "Reference Error" because the function doesn't exist, but that's ok.
The rule number 2 of TDD clearly says:
[…]; and compilation failures are failures
In this context, you can read the rules as:
[…]; and reference errors are failures
Alright, so I assume that error is a regular test failure. However, a "fix" in this context is not going to result in a passing test, it's going to result in a different error message.
Now the failure comes from the QUnit assertion. It looks like I've fixed it.
If the user asks for a loan of $2, the interest is going to be zero..
If I keep incrementing one dollar over and over again, there's nothing that can drive me to change the code to make it more generic. A new test that follows this pattern gives me no value whatsoever. It will always pass because every loan amount that returns no interest will be valid until I start to test the next boundary of $2001 or more.
It's time to change the strategy.
However, although a new passing test doesn't provide value to my Test-Driven approach, it helps me to understand whether it makes sense to DRY.
Notice I've duplicated the code for the third time. Therefore, as I wrote in the past, there's enough evidence that I can refactor the code into a reusable function.
There's one thing I find very helpful when testing the boundaries of a problem, which is to test the surroundings at least three times. In the context of "Jack, The Moneylender" problem, the surroundings are the loan amounts of $2000, $2001 and $2002.
Note: For the sake of brevity, I'll stop referring to the red and green tests separately. However, assume there's always a failing test for every snippet. If you go to the Github project, you can see the passing and failing test in each commit separately.
The $2000 loan amount doesn't help much, the test just passes because it's in the same range as before. I still expect it to be zero.
Let's try $2001. It should not return zero, but 9 cents.
Here you can see a widespread software development mistake: the use of floating point numbers to represent currency. There is a lot of content out there about this subject. Therefore, I won't get into the details here.
To understand the precision impact my code would have, I created a JSFiddle to simulate the dollars between $2001 and $5000:
Note: To avoid going on tangents and deviate from the main subject of this post which is TDD, I'll use the JavaScript "Number" type as if it could represent a currency correctly. There's another post that shows how to convert this into a proper monetary model.
If we continue the path we were going, we'll eventually have a production code that looks like this:
I can see a lot of Bad Code Smells in the code. The most annoying one seems to be the duplication that is a result of the sum of the constants that represent the 9 cents. It makes sense to refactor the code to use multiplication.
Now I can see a pattern. Although writing dumb conditionals seems naive at first, they are showing to me that for every dollar above $2000 I can multiply that amount by 9 cents. Of course, until I reach the next range of $5001.
Also, I can change the Magic Number that represents the right-hand multiplication factor. If I put the "loan amount" minus the last value of the previous range, I'll always get an incremental result for each value inside the conditionals.
Now I’ve spotted a great opportunity to think incrementally!.
The requirements say I need to support a lot of ranges. However, if Jack is ok with it, he can have a working software right now. He can paste the function in Chrome DevTools and see if the loan amount he's giving has “no interest” or how much he should charge on the basis of 9 cents per dollar. For a loan bigger than $2001, he needs to wait until I can support the other ranges.
I asked Jack, and he’s impressed. He didn’t even know such a thing was possible! He thought that he would have to wait a lot of time to get the software with all the requirements done. Most of his customers get a loan that is around the $2000 range, so that approach has big value to him!
To produce valuable software, I need to remove all other conditionals and only leave the first one. Also, I need to change the equality comparison to "greater than or equal."
The next step can be to add descriptions for each boundary of the problem in the tests.
It's not done yet.
I need to remove all remaining Magic Numbers. After all, who ships code with constants that are meaningless to the next developer? I don't.
Once that’s done, then I can say I have a deliverable software; only it doesn't have all the features baked in. If I want to, I can code a dumb web server that just prints out the result as HTML and use the function in the back-end.
If you have a fixed specification and a set deadline, you have to think outside that black box
I've been given a problem with a fixed specification and a set deadline. TDD has helped me to figure out flaws in the original specification. Also, by merging the incremental thought with the process of TDD and focusing on the problem at hand, I managed to cut down delivery time. I wouldn't be able to do that if I had started coding everything in one go.
A Big bang mindset.
Maybe the end result is not the best solution. Test-Driven Development is not about being perfect, it's about writing code so that the tests can drive you to some solution. It helps you to build up a better understanding of the problem. It doesn't turn you magically into a great coder, problem solver or software designer.
Also, TDD can serve as a discipline to help document your thoughts in the tests and write code at the same time. That's very useful for Pair Programming and Mob Programming, where nobody knows the details of the beetle each other's head.
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.14 of interest rate for every dollar above $5000. Also, I'll make some changes to the test descriptions so that they use the language of the domain, not the language of the developer.
Stay tuned đź“».
Edit November 8, 2018: Here's the next post
Thanks for reading. If you have some feedback, reach out to me on Twitter, Facebook or Github.
Thanks to Jon Eaves, Jay Bazuzi, Riccardo Odone, Siddharth Salunke, Stephan Classen and Leonti Bielski for their insightful inputs to this post.