Not as Painful as it sounds…
Nothing is more pleasing than beautiful code. And nothing is more heart-breaking than watching beautiful code get destroyed.
Lately, I’ve been paying particular attention to SOLID Object Oriented Design (OOD) principles and their interaction with TDD. I’m finding that, while TDD is an essential first step, it just isn’t enough. If I want my code to survive the rigors of change and be useful for a long time I need to armor it by following SOLID principles.
There’s a delightful symmetry in the feedback loop between TDD and OOD. TDD in isolation is not guaranteed to produce well designed code, but if you follow the OOD principles while writing the code you’re testing, TDD gets easier and your tests get better.
In case you feel like pushing back already, let me add an early caveat. If you
- have an extremely simple application
- with a specification that’s 100% complete
- that will never ever change
go on ahead and write it any way you’d like. It doesn't matter.
I say good luck with that. Let me know how it works out for you.
But if you’re living in my reality, have a listen to Uncle Bob. In Design Principles and Design Patterns he describes good software as "clean, elegant, and compelling", with "a simple beauty that makes the designers and implementers itch to see it working".
But then goes on to say:
"What goes wrong with software? The software starts to rot. At first it isn’t so bad. An ugly wart here, a clumsy hack there, but the beauty of the design still shows through. Yet, over time as the rotting continues, the ugly festering sores and boils accumulate until they dominate the design of the application. The program becomes a festering mass of code that the developers find increasingly hard to maintain."
There’s more, but if I told you all of it I’d have to send you to the dermatologist.
If you have good tests, you can protect the reliability of any smelly pile of software, even if it makes you cry when you have to change the code. $$’s fly out the window but it all still works.
If you have good tests AND good design, the software will be reliable and changes will be an economical pleasure. You’ll look forward to adding new features so you can undertake big refactorings and make the code do even more.
But, enough with all this talk. Let’s do something.
Dependency Injection
Bob calls it Dependency Inversion and you could definitely argue that the two concepts are slightly different, but don’t quibble with me about this. I’m being practical here.
Example 1:
class Job
def run
@retriever = FileRetriever.new
strm = @retriever.get_file(‘theirs’)
@cleaner = FileCleaner.new
cleaned = @cleaner.clean(strm)
local = ‘mine’
File.open(local, ‘w’) {|f| f.write(cleaned) }
local
end
end
Class Job
is responsible for retrieving a remote file, cleaning it up and then storing it locally. It uses two preexisting classes, FileRetriever
and FileCleaner
, which themselves have thorough tests.
The Job
class is dirt simple. If you wrote it test first, you might have a spec like:
it “should retrieve ‘theirs’ and store it locally” do
@job = Job.new
local_fn = @job.run
local_fn.should have_the_correct_contents
end
What does this spec test? Job
? Or Job
AND FileRetriever
AND FileCleaner
? Obviously, all three. My spec is testing a whole set of objects; Job
and all of it’s dependencies. It’s fragile in that it relies on objects other than the one under test and it runs too long because it exercises code that it should not care about.
Mocks/stubs to the rescue, right? I could stub FileRetriever.get_file
and FileCleaner.clean
and bypass both of those problems. However, even if I stub those methods, my code still has a bad smell. Stubbing improves the test but does not fix the flaw in the code.
Because of the style of coding in Job
, it contains dependencies that effect my ability to refactor and reuse it in the future. Let’s move some code around.
Example 2:
class Job
attr_reader :retriever, :cleaner, :remote, :local
def initialize(retriever=FileRetriever.new, cleaner=FileCleaner.new,
remote=‘theirs’, local=‘mine’)
@retriever = retriever
@cleaner = cleaner
@remote = remote
@local = local
end
def run
strm = retriever.get_file(remote)
cleaned = cleaner.clean(strm)
File.open(local, ‘w’) {|f| f.write(cleaned) }
local
end
end
Now I’m injecting the dependencies into Job
. Suddenly, Job
feels a lot less specific and a lot more re-usable. In my spec I can create true mock objects and inject them; I don’t have to stub methods into existing classes.
That stylistic change helped a lot, but what if I want to provide some, but not all, of the arguments? It’s easy, just change the initialize method. It wouldn’t bother me if you also wanted to simplify run.
Example 3:
class Job
attr_reader :retriever, :cleaner, :remote, :local
def initialize(opts)
@retriever = opts[:retriever] ||= FileRetriever.new
@cleaner = opts[:cleaner] ||= FileCleaner.new
@remote = opts[:remote] ||= ‘theirs’
@local = opts[:local] ||= ‘mine’
end
def run
File.open(local, ‘w’) {|f|
f.write(cleaner.clean(retriever.get_file(remote)))}
local
end
end
That feels really different from example 1. A simple change in coding style made Job more extensible, more reusable and much easier to test. You can write code in this style for no extra cost, so why not? It will save someone pain later.
Example 4 – Pain:
Here’s some code from Rails that generates xml for ActiveRecord objects. (Please, I’m not picking on them, this just happens to be a good example that I dealt with recently.)
module ActiveRecord #:nodoc:
module Serialization
def to_xml(options = {}, &block)
serializer = XmlSerializer.new(self, options)
block_given? ? serializer.to_s(&block) : serializer.to_s
end
#…
end
end
Without recounting the whole story, I wanted to use to_xml
with a different Serializer
class. Imagine how easy it would be if XmlSerializer
had been injected into to_xml
. Instead, look at it and despair. I have to override the entire method just to name a different serializer class, with all the future maintenance burden that the change entails.
The to_xml
code does exactly what it’s supposed to do and in that way cannot be faulted. The person who wrote it isn’t bad, they just never imagined that I would want to reuse it this way.
Let me repeat that.
They never imagined how I would reuse their code.
The moral of this story? The same thing is true for every bit of code you write. The future is uncertain and the only way to plan for it is to acknowledge that uncertainty. You do not know what will happen; hedge your bets, inject your dependencies.
TDD makes the world go ‘round. It lets us make unanticipated changes with confidence that our code will still work, but SOLID design principles keep the code ’clean, elegant, and compelling‘ after many generations of change.
Notes:
- I don’t mean to be overly familiar; it’s not like I know the man. But he’s an icon, how can I avoid calling him ‘Bob’?