On TDD
& Writing Good Tests

by Javier Treviño Saldaña

Just like many in the industry,

I care about my craft and want to improve. I learn from the existing body of knowledge, practice the patterns and gradually gain proficiency in them. Through my journey I pay attention to the hits & misses of each pattern, ultimately developing a sense of when it's a good idea to use one or not.

Test-driven development is a pattern we use daily at Dynamic.Tech. In my experience as a consultant helping companies improve the quality of their code, I found many teams had given up before gaining proficiency in this practice. They were missing out on having:

  • Living documentation (tests show you how to use the parts of a system)
  • A safety net (when a code change causes an unexpected behavior change -> a test fails)
  • Early & constant design/architecture feedback

I used to work at, and still collaborate with, a company which inherited practices from Uncle Bob. He's one of the main proponents of TDD & other patterns.

TDD requires proficiency in other patterns in order to be effective.

You've probably heard about the SOLID principles. Applying them to our production & test code has given us the benefits of TDD I previously described. The guidelines below stem from the principles.

Writing Good Tests

1. Test the behavior your unit cares about

Using the FizzBuzz kata as an example:

Write a program that prints the numbers from 1 to 100. ...but for multiples of three print "Fizz" instead of the number. ...and for the multiples of five print "Buzz". ...For numbers which are multiples of both three and five print "FizzBuzz". Example Output: 1, 2, Fizz, 4, Buzz, [...], 13, 14, FizzBuzz, ...

We don't care *how* we implement the solution. We care about the following behavior:

  • Multiples of 3 print "Fizz"
  • Multiples of 5 print "Buzz"
  • Multiples of both print "FizzBuzz"
  • Prints every other number

So that's what we aim to cover in our tests.

2. Use the public interface of your unit

This is where you get design feedback. How would you like this unit to look like to other elements in your system?

expect(FizzBuzz.generate(3)).to eq([1, 2, "Fizz"])
3. One reason to change (and fail)

The code example above intended to verify the first behavior:

- Multiples of 3 print "Fizz"

Yet, by comparing against the entire list, we're also covering:

- Prints every other number

The problem is evident when we add more test cases to verify multiples of three:

expect(FizzBuzz.generate(9)).
  to eq([1, 2, "Fizz", 4, 5, "Fizz", 7, 8, "Fizz"])

The previous assertion now overlaps with yet another behavior:

- Multiples of 5 print "Buzz"

When we implement "Buzz" then the code above would break ("Expected 5, got Buzz"). Having one reason to change will help our test case resist even if other behaviors are added, or break.

Instead:

list = FizzBuzz.generate(9)

[2, 5, 8].each do |n|
  expect(list[n]).to eq("Fizz")
end
4. Reveal intention

Help future developers understand the unit's behavior. When something breaks, your test case should explain why the expectations were so.

it "returns 'Fizz' on multiples of three" do
  list = FizzBuzz.generate(100)

  [3, 6, 9, 15, 48, 72, 99].each do |n|
    expect(list[n-1]).to eq("Fizz"), n
  end
end

That last `n` parameter in the assertion complements the failure message (in our testing framework). When the test fails it'll show a helpful value:

Failure on line X:
  "Expected Fizz, but got FizzBuzz - 15"