As the tests get more specific, the code gets more generic
- Prime Factors Kata
- Test code and production code do not grow at the same rate (The test code grows faster)
- Sometimes the production code stays the same as the test code grows
- You are done with the code but you still write the tests because they are part of the specifications
- Sometimes the production code shrinks as the test code grows
- Because the programmers moves a load of functionality out of the code and into the data
- Or comes up with some kind of more general algorithm that satisfies the tests without looking anything like them.
-- from As the Tests get more Specific, the Code gets more Generic - Clean Coder
FizzBuzz Kata
I started practicing Coding Katas1 to sharp my TDD/OOD skills several days ago.
When I was doing this first Kata, one of my colleagues (non-programmer) was watching me. So I explained TDD to her.
She asked this question later: "But your tests cannot cover all the cases (all the integers), how can you ensure your code is absolutely right?"
"As the tests get more specific, the code gets more generic" is a part of my answer. (For other reasons, like programmer confidence stuffs, I may talk about them in another post.)
Case #1
Test
def test_sound_for_1_is_1 assert_equal '1', FizzBuzz.for(1) end
Code
def self.for(_number) '1' end
At this point, we only have one specification, so the code is pretty simple. (We are following TDD here, so this silly code is completely acceptable because it's green code.)
Case #2
Test
def test_sound_for_2_is_2 assert_equal '2', FizzBuzz.for(2) end
Code
def self.for(number) if number == 2 '2' else '1' end end
We are able to see a pattern here (number
-> number.to_s
). But I
chose to using if
and wait for the next test. Because the next test
seems to be very different.
Case #3
Test
def test_sound_for_3_is_Fizz assert_equal 'Fizz', FizzBuzz.for(3) end
Code
def self.for(number) if number == 3 'Fizz' elsif number == 2 '2' else '1' end end
The code starts to look ugly now, but fortunately it's still green. Let's move to our next test.
Case #4
Test
def test_sound_for_4_is_4 assert_equal '4', FizzBuzz.for(4) end
Code
def self.for(number) if number == 3 'Fizz' else number.to_s end end
We got our third case for number.to_s
and at this point I decide to
refactor it this way (Because of the magic number 3
).
We can see here that the code gets more generic (it handles more
cases), while the test gets more specific (it adds a new
specification for input 4
).
Case #5
Test
def test_sound_for_5_is_Buzz assert_equal 'Buzz', FizzBuzz.for(5) end
Code
def self.for(number) if number == 3 'Fizz' elsif number == 5 'Buzz' else number.to_s end end
The same things goes with 5, Buzz
pairs as 3, Fizz
pairs. The code
starts from being very specific (only deals with 3
or 5
), as the
test gets more specific (case 6
or 10
are added), it gets more
generic (deals with numbers have factor 3
or 5
).
Case #6
Test
def test_sound_for_6_is_Fizz assert_equal 'Fizz', FizzBuzz.for(6) end
Code
def self.for(number) if number % 3 == 0 'Fizz' elsif number == 5 'Buzz' else number.to_s end end
Again, the code gets more generic while the test gets more specific.
Extracting logic to other classes
Later, when the logic for FizzBuzz
gets more and more complicated, I
decided to push logic out of FizzBuzz
class and extract some new
concepts in our app.
class FizzBuzz def initialize(sounds) @sounds = sounds end def for(number) sounds .detect { |sound| sound.convertable?(number) } .for(number) end private attr_reader :sounds end
I'll explain more about this refactoring in another post. But the
basic idea is letting sounds to deal with different conversion rules
(Factor3Sound
for converting number to Fizz
, StringSound
for
converting number to number.to_s
, etc.)
Again, the FizzBuzz
code gets more and more generic. Because it's
now a sound
rules chain, delegating conversion rules to sound
objects, and just returning the result from the first sound
that can
handle the input number
.
I'll stop our FizzBuzz
journey here as it has taken us so long. And
I will talk about "what I learned after I played with FizzBuzz
for 3
days" (Chain of Responsibility, and Elixir solution, etc.) in a future
blog post.
DRY is not that important for tests
We programmers often says that our code needs to be DRY (Don't Repeat Yourself). And I think we can also say "as the tests get more specific, the code gets more generic" in this way:
DRY up your code as your tests repeat more times.
When we dry up our code, we are actually making our code more generic/abstract. This is good because it lets us handle as many possible cases on production as possible.
But our test doesn't need to be as DRY as our production code. After all, a test case should be as specific as possible, because it should be a specification for a case.
The most important thing we need to think about when we write a test should be making this test as clear as possible. I should be able to understand what this specification is talking about by only looking at this test itself.
If we try to DRY our test cases as well (like using let
or before
in RSpec), we may introduce too many Mystery Guests
2 and making
these tests unreadable.
However, this doesn't mean we should not DRY our tests at all. It's still necessary to extract common assertions/matchers3 to make your tests more readable. Our tests need to be DRY as well, but in a different way from the production DRY.
A healthy Test-to-Code Ratio
With this principle being said, I think the Test-to-Code ratio can be a pretty good guide for us to decide when to refactor our code. (Alongside with Sandi Metz's Rules)
IMHO, a good Test-to-Code ratio for a Rails project should be between 1:1 to 2:1. But the ratio definitely differ between different languages/frameworks/test tools.
Use Test-to-Code Ratio to guide your TDD cycle
(I'll use 1:1 to 2:1 in the following explanations.)
- When Test-to-Code Ratio is lower than 1:1
- Consider to add more test cases.
- Consider if the production code is too DRY.
- When Test-to-Code Ratio is higher than 2:1
- Consider to DRY the production code by.
- Consider if the production code has too many responsibilities and needs to be split.
TDD is a process guided by this thinking
As I wrote in Book Review: 99 Bottles of OOP, we can only achieve Shameless Green by following the TDD cycle and make our code a step more abstract (generic) in each cycle.
I think this thinking explained this methodology pretty well.