The Missing Practical Step-By-Step TDD 🔊

So you have a fixed specification and deadline? Sorry, that's not an excuse

Fagner Brack
ITNEXT

--

The picture of a bald man with a short beard. He’s counting some money in a table and smiling. There's a dark shadow surrounding him.
Listen to the audio version!

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:

The diff for the commit which represents the setup of the test environment.

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.

đź”´ The diff for the commit that changes a dummy assertion to use the custom function "strict equal."
âś…The diff for the commit that creates the custom function "strict equal" and makes the test pass.

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."

đź”´ The diff for the commit that adds the test for $1 loan amount.

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."

The diff for the commit that set the variable "interest to pay" to 0.

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."

đź”´ The diff for the commit that adds the test for $2 loan amount.

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

— Rule of TDD number 2

In this context, you can read the rules as:

[…]; and reference errors are failures

The screenshot for the QUnit test failure. It fails with a Reference Error; the message is that the function "interest to pay for dollars" is not defined.

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.

đź”´ The diff for the commit that defines the function "interest to pay for dollars."

Now the failure comes from the QUnit assertion. It looks like I've fixed it.

The same screenshot as before, only now it shows an assertion failure. It reads "expected: 0", "result: undefined."

If the user asks for a loan of $2, the interest is going to be zero..

âś… The diff for the commit that returns the expected result from the function "interest to pay for dollars."

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.

âś… The diff for the commit that adds the test for $3 loan amount.

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.

🔨The diff for the commit that refactors the test to reuse the function "interest to pay for dollars."

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 diff for the commit that adds the test for $2000 loan amount.

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.

đź”´ âś… The diff for the commits that create the code for a loan amount of $2001.

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:

đź”´ âś… The diff for the commits that create the code for loan amounts of $2000, $2001, $2002 and $2003.

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.

🔨The code for the commit that refactors the code to change the sum to 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.

🔨The diff for the commit that refactors the code to calculate the dollar value for each condition dynamically.

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 diff for the commit that refactors the code to remove unnecessary conditionals.

The next step can be to add descriptions for each boundary of the problem in the tests.

🔨The diff for the commit that refactors the code to separate the tests in blocks.

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.

🔨The diff for the commit that refactors the code to remove all Magic Numbers.

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.

--

--

Writer for

I believe ideas should be open and free (as in Freedom). This is a non-profit initiative to write about challenging stuff you won’t find anywhere else.