Ibrahima Ciss
Posted on January 4, 2021
I was recently reading Test-Driven Development book by Kent Beck, and it doesn’t take me long to realize that I was doing TDD wrong. I started this practice a couple of years ago, and I would never have thought in my wildest dream doing it the wrong way, but here I am.
First let’s revise TDD principles, shall we?
- Write a failing test (Red)
- Write the minimum amount of code to make the test passes (Green)
- Remove duplications (Refactor)
What I was doing completely wrong was step 2 and 3.
Doing TDD the wrong way
To illustrate that I’ll take a simple FizzBuzz example. Again, let’s see the requirements of this kata before implementing it (the wrong way):
- For multiple of three, print “Fizz” instead of the number
- For multiple of five, print “Buzz”
- For numbers which are multiples of both three and five, print “FizzBuzz” So, I would create the test class and write the first failing test:
import XCTest
class FizzBuzzTests: XCTestCase {
func test_it_returns_fizz_for_multiple_of_three() {
[3, 6, 9, 12].forEach {
XCTAssertEqual("fizz", FizzBuzz.convert($0))
}
}
}
At this phase, the code won’t even compile because there is no class or struct called FizzBuzz. Let’s go ahead and create it.
struct FizzBuzz {
}
Again it doesn’t compile because there is no convert method, let's add it.
struct FizzBuzz {
static func convert(_ number: Int) -> String {
}
}
At this point, we have just one error left saying “Missing return in a function expected to return 'String’” and it’s right, we say the convert method should return a String, but we return Void instead.
Now the wrong part of me doing TDD (I can’t even call it TDD now, shame on me 🤧), I would directly put the concrete implementation of that method like so:
struct FizzBuzz {
static func convert(_ number: Int) -> String {
if number % 3 == 0 { return "fizz" }
return ""
}
}
Now, I have no compilation error and If I run the test, it passes successfully, and you can see a smile on my face. Let’s continue by writing the remaining tests.
import XCTest
func test_it_returns_fizz_for_multiple_of_three() {
[3, 6, 9, 12].forEach {
XCTAssertEqual("fizz", FizzBuzz.convert($0))
}
}
func test_it_returns_buzz_for_multiple_of_five() {
[5, 10, 20, 25].forEach {
XCTAssertEqual("buzz", FizzBuzz.convert($0))
}
}
func test_it_returns_fizzbuzz_for_multiple_of_three_and() {
[15, 30, 45, 60].forEach {
XCTAssertEqual("fizzbuzz", FizzBuzz.convert($0))
}
}
func test_it_returns_the_original_number_if_not_divisible_by_three_or_five() {
[1, 2, 4, 7].forEach {
XCTAssertEqual("\($0)", FizzBuzz.convert($0))
}
}
}
Now let’s write the complete struct that’ll make all these tests pass.
struct FizzBuzz {
static func convert(_ number: Int) -> String {
if number % 3 == 0 && number % 5 == 0 { return "fizzbuzz" }
if number % 3 == 0 { return "fizz" }
if number % 5 == 0 { return "buzz" }
return "\(number)"
}
}
When we run the tests, they all pass. For me, this was completely correct because I wrote the failing test, then the code that make the test passes (even though it’s not the minimum amount of code necessary to turn to green), and what about the third step, the refactoring phase? Well, I would do probably something like this:
struct FizzBuzz {
static func convert(_ number: Int) -> String {
var result = ""
if number % 3 == 0 { result += "fizz" }
if number % 5 == 0 { result += "buzz" }
return result.count > 0 ? result : "\(number)"
}
}
The code looks sexier even though we have the same number of lines and the tests still pass. At this point, I’m all happy, and I’ll call it a day.
This is not how TDD is supposed to work, this is just a cycle of writing a failing test and making it pass. What about the step 2, which states to write the minimum amount of code necessary to make the test pass? And step 3 where we’re supposed to refactor by removing duplication?
Ok, but one particular question arises “Does it even matter if the code works? I mean, at the end of the day, we have a good implementation of our fizzbuzz kata and a suite of tests validating it correctness”. Yes, for me, it does. Doing things this way might work for sure, but it’s violating the core principles of TDD and as Kent said in his book:
“Quickly getting that bar to go to green dominates everything else. If a clean, simple solution is obvious, then type it in. If the clean, simple solution is obvious, but it will take you a minute, then make a note of it and get back to the main problem, which is getting the bar green in seconds.” which just means we have to make the test pass as quickly as possible and refactor afterwards. Now let’s do TDD the right way.
Doing TDD the right way (Fake it, ’til you make it)
Now we’ve seen how I was doing TDD the wrong way, let’s see how things should really work. For that, I’m going to take the same example as above.
The first step is to write a failing test. Easy enough.
import XCTest
class FizzBuzzTests: XCTestCase {
func test_it_returns_fizz_for_multiple_of_three() {
[3, 6, 9, 12].forEach {
XCTAssertEqual("fizz", FizzBuzz.convert($0))
}
}
}
The second step is to write the least required code to make the test pass (remember, for this one, I would create the struct and the convert method then fill it with the concrete implementation like I did above). This time, the code would look like this.
struct FizzBuzz {
static func convert(_ number: Int) -> String {
return "fizz"
}
}
When we run the test, of course it passes. We went to green as quickly as possible: we fake it by using stubs. And yes, this is not the correct implementation, but remember, we want to go green as soon as possible, no matter what.
The third step is refactoring to remove duplications. Hum, ok, but with this code, where is the duplication? Usually, you see duplication between two pieces of code, but here the duplication is between the data in the test and the data in the code (aha 💡). So, we’ll replace our code by the correct implementation:
struct FizzBuzz {
static func convert(_ number: Int) -> String {
var result = ""
if number % 3 == 0 { result += "fizz" }
return result.count > 0 ? result : "\(number)"
}
}
And we can repeat the whole cycle for the remaining tests. Doing things this way has a couple of effects that make “Fake It” powerful:
- Psychological: having a green bar feels completely different from having a red bar. When the bar is green, you know where you stand. You can refactor from there with confidence.
- Scope control: programmers are good at imagining all sorts of future problems. Starting with one concrete example and generalizing from there prevents you from prematurely confusing yourself with extraneous concerns. You can do a better job of solving the immediate problem because you are focused. When you go to implement the next test case, you can focus on that one, too, knowing that the previous test is guaranteed to work.
Conclusion
The part I totally missed I think was the refactoring one. I was getting trapped by making things work as soon as possible with the correct implementation without using any sort of stubs or mocks which results of me violating the rules. The second mistake was whenever I heard the word “refactor”, I thought it refers to moving code around, making it look sexier, re-run the tests suite to ensure everything is still working, etc... Sure that’s refactoring, who can blame you for thinking that way? But from a TDD perspective the true meaning of the word is to remove duplications by removing the stubs (generally constant values) and replacing them with their correct implementation. If you want to learn more about TDD, I highly recommend Kent’s book: Test-Driven Development By Example, you can find it on Amazon.
Posted on January 4, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.