Oh yeah, we do TDD, after all we’re an agile team! That’s what we tell our peers and it is even true, it is just not true 100% of the time. But everyone kind of agrees not to dig too deep – after all they are in exactly the same boat – and we all get to feel good about our process and how we do things in our neck of the woods. Let’s face it, we all fall back into non-TDD practices every day, that doesn’t mean we don’t write tests it just means we don’t always write the tests first. For some reason people often feel like they need to cover this up, as if they loose some credibility by not being a TDD maniac and that’s patent nonsense. In the kind of work we do as developers, it is perfectly natural to not be doing TDD all the time, the breadth of technology we work with on a daily basis almost demands this. Let’s examine a typical TDD scenario (or at least typical for me) and perhaps things will get a little clearer.
A Typical TDD Scenario
We have a class and we feel the need for a new method, we write an empty method definition and we’re now almost ready to TDD.
ruby
class FileOperations
def read_file_and_print_line
end
end
Of course we don’t just start hacking away, we need to build a picture in our head of what we want our new method to do. In this case we have the following:
- find the path of the file we want to open
- open the new file and read it line by line
- when we find the relevant line we want to print it out
- the line is considered relevant if it matches a certain condition
We’re now ready to get testing:
```ruby describe “FileOperations” do before(:each) do @file_ops = FileOperations.new end describe “read_file_and_print_line” do it “should match a line in a file” do
1 2 3 |
end it "should not match a line in a file" do end |
end end```
The fact that we’re trying to match against a condition is an immediate flag that we can have a positive and negative outcome so we require 2 tests. If you haven’t got the second test you will easily be able to implement the method incorrectly without your test picking this up. Alright, lets fill in our tests and then pick them apart.
```ruby describe “FileOperations” do before(:each) do @file_ops = FileOperations.new end
describe “read_file_and_print_line” do it “should match a line in a file” do @file_ops.should_receive(:find_path_of_file_to_open).and_return(“path to file”)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
mock_file = mock(File) File.should_receive(:open).with("path to file").and_yield(mock_file) mock_file.should_receive(:each).with(no_args()).and_yield("string1").and_yield("string2").and_yield("string3") @file_ops.should_receive(:line_matches_condition).with("string1").and_return(false) @file_ops.should_receive(:line_matches_condition).with("string2").and_return(true) @file_ops.should_receive(:line_matches_condition).with("string3").and_return(false) @file_ops.should_receive(:puts).once().with("string2") @file_ops.read_file_and_print_line end it "should not match a line in a file" do @file_ops.should_receive(:find_path_of_file_to_open).and_return("path to file") mock_file = mock(File) File.should_receive(:open).with("path to file").and_yield(mock_file) mock_file.should_receive(:each).with(no_args()).and_yield("string1").and_yield("string2").and_yield("string3") @file_ops.should_receive(:line_matches_condition).with("string1").and_return(false) @file_ops.should_receive(:line_matches_condition).with("string2").and_return(false) @file_ops.should_receive(:line_matches_condition).with("string3").and_return(false) @file_ops.should_not_receive(:puts) @file_ops.read_file_and_print_line end |
end end```
There are many things going on so we’ll start at the beginning.
- We want to cover our method with a minimum number of tests, this allows us to keep our methods small and tight and makes it easier for everyone. This means that whenever there is an opportunity to push some functionality into a collaborator we need to take it. In the case of our tests we did this twice _find_path_of_file_toopen and line_matches_condition. We don’t care how these collaborator methods work, we can figure it out later, for now we simply mock how we expect these methods to behave.
- Because we know a little bit about Ruby file system access we know that when we open a file we can yield to a block so we need to represent this in our test (to enforce this behaviour on our method implementation). We also know that we can call each on our file which will allow us to yield each line of the file to a block. Enforcing things like this can potentially make the test brittle if we decide to change our mind about the internal implementation of our method, but it also protects us from implementing the method incorrectly and having our tests still pass (more on this later).
- We’ve set our tests up is such a way that we know how many strings should match and so we know that one line should be printed out in the first test and no lines in the second, we need to be explicit about this, once again, this ensures that the actual implementation can only go one way.
Essentially our two tests ensure that the easiest way to implemented the method is also the correct way, which is the goal of TDD. If you find that your tests allow you a much easier path to implement your method incorrectly (and still have the tests pass) then you need to tweak your tests. Oh and here is our finished method, only one way to write it now:
```ruby class FileOperations def read_file_and_print_line currently_script_file = find_path_of_file_to_open File.open(currently_script_file) do |file| file.each do |line| puts line if line_matches_condition line end end end
private def find_path_of_file_to_open end
def line_matches_condition line end end```
The two collaborators are still empty, we can now TDD them and fill in their implementation.
What We Learned
Having gone through the above exercise a couple of things become abundantly clear:
- We form a more or less complete picture in our head of where we want our method to go before we start writing the tests or the implementation
- We need to have a reasonably good understanding of the tech we’re working with in order for us to write our tests first (i.e. how Ruby IO works, blocks, etc.)
What if you’re new to Ruby and have no idea how file IO works, what picture will you form in your head (a blank page) and how will you evolve the functionality through tests (with great difficulty). You’re much more likely to go off and do a little bit of research and try some stuff out in order to understand the tech you’re dealing with and of course as you’re trying stuff out, the solution to your current problem will naturally take shape and your method is more or less done. Once you’re fairly confident of what you’re doing you can delete and start over or you can just write the tests after the fact. In my opinion either way is correct. Of course I picked something simple to illustrate my example (Ruby file IO), but we run into this kind of situation all the time in our day to day work as developers. I need to write some code using a framework, API, language I am not really familiar with or am not an expert in, how do you fit TDD into that scenario – not easy?
And before we bring up the whole Spike argument, lets be clear. The research you do into tech you’re working on (but are not very familiar with) is not really a Spike, it is certainly Spike-like but it is not explicit. A spike has to be explicit (i.e. there is card for it). On the whole I consider this “research”, part of natural knowledge acquisition, so there is no need to throw your code out after you finished “doodling” (although you certainly can if you’re so inclined and are not pressed for time).
The amount of APIs, libraries, DSLs, formats, standards that a typical project deals with can be truly formidable, noone can be an expert on all of it. And by the time you start to get a handle on things you move on to a different project with a different tech stack and you’re “adrift” again. And when you come back to more familiar ground again it is not so familiar any more and so the cycle continues. Is it any wonder at this point that we tend to fall back into non-TDD from time to time?
Being Careless
The argument is that TDD lets you evolve your tests so that both your code and your tests are better as a result. I say being careful and thinking about what you’re doing is what allows you to have better code and better tests. Lets say we wrote the method above first and then decided to write the tests afterwards, we could potentially end up with something like this:
```ruby describe “bad tests read_file_and_print_line” do it “should forget to yield” do @file_ops.should_receive(:find_path_of_file_to_open).and_return(“path to file”)
1 2 3 4 |
mock_file = mock(File) File.should_receive(:open).with("path to file").and_return(mock_file) @file_ops.read_file_and_print_line |
end
it “should forget to output” do @file_ops.should_receive(:find_path_of_file_to_open).and_return(“path to file”)
1 2 3 4 5 6 |
mock_file = mock(File) File.should_receive(:open).with("path to file").and_yield(mock_file) mock_file.should_receive(:each).with(no_args()).and_yield("string1").and_yield("string2").and_yield("string3") @file_ops.read_file_and_print_line |
end end```
Both of those tests pass, but are clearly nowhere near as good as our original pair. But being careful and conscientious developers we know this already, we wouldn’t leave them in this state. If we didn’t know our tech well enough and thought that these two tests were fine, no amount of TDD would have helped. It’s not about the TDD it’s about the knowledge, practice, attitude and experience.
The worst thing in my opinion is when you try to force the use of TDD where you would be better off without it. You don’t know the tech and yet you try to force the tests (which end up crap anyway), spend exorbitant amounts of time on them and get nowhere. In this situation TDD can seriously slow you down and when deadline is of the essence can you really afford that?
Look, TDD can be a great tool in your arsenal as a developer (especially if you can manage to TDD your acceptance or integration tests i.e. black box) but there is no need to be a purist about it and there is no need to feel guilty when you choose not to employ this particular tool.
For more tips and opinions on software development, process and people subscribe to skorks.com today.
Image by onkel_wart