Let’s say you have some code that doesn’t have tests and you want to add tests. Because the code wasn’t written with tests in mind, it might be hard to write tests for it.
Last year, DHH wrote a blog post called Test-induced design damage which argued that code “written with tests in mind” (quoting myself from the previous paragraph, not his post) isn’t necessarily better than code written with other goals in mind, and can often be worse.
When I’ve attempted TDD, I’ve had times when I felt like it helped me write nice code and times where I think I went too far like if you’re rolling out some dough to make a pie crust but you’re watching TV and you end up spreading it out until it covers the whole counter.
So this code you want to test. What makes it hard to test? Let’s say it’s a Ruby class in a Rails app. In Rails apps, all the classes are available for all the other classes to reference and depend on. Maybe it looks like this:
class PieCrust
def initialize(pounds_of_dough)
@lbs = pounds_of_dough
@ready = false
end
def prep
RollingPin.new.roll_out(self)
Oven.new.preheat
Instagram.upload(self)
@ready = true
end
def ready?
@ready
end
end
(This example is revealing more about my state of mind right now than anything)
But like, look at this thing. How do we write a test that covers all that? And what if we want “fast tests”?
(Note: a lot of people really want their tests to run fast. For TDD enthusiasts, this allows a tight feedback loop between when you write the failing test to when you write the code which makes the test pass. They kind of expect to do that over and over and over and don’t want to wait for more than an instant. I don’t think they’re wrong to want that although I am personally often OK with waiting for longer than an instant.)
(Other people want their tests to run fast as a general principle, like they want their cars to go fast or their legs to.)
Let’s say there are a couple hundred classes in your app and a bunch of
initializers which run whenever your Rails application is loaded and none of
them are strictly necessary for you to feel confident that your PieCrust
class
is behaving properly. All you want to know is that calling the prep
method
rolls out the crust, preheats the oven, and uploads the pie crust to instagram.
You already know that all those things work as long as you call them properly because you have unit tests for those classes demonstrating how to call them properly. You can see that here they’re being called properly, so you don’t feel the need to actually load the rolling pin code, or the oven code, or the instagram code. And you really don’t want to upload something to instagram every time you run your tests.
What do you do?
There’s the dependency injection approach, where you might refactor the earlier code to look like:
class PieCrust
def initialize(pounds_of_dough, roller: RollingPin.new, heater: Oven.new, photo_sharing_service: Instagram)
@lbs = pounds_of_dough
@roller = roller
@heater = heater
@photo_sharing_service = photo_sharing_service
@ready = false
end
def prep
roller.roll_out(self)
heater.preheat
photo_sharing_service.upload(self)
@ready = true
end
def ready?
@ready
end
private
attr_reader :roller, :heater, :photo_sharing_service
end
Which lets you leave your other application code the same – it can interact with PieCrust the same as it did before, as the default values are totally sensible there. But you can now write a test like this:
RSpec.describe PieCrust do
describe '#prep, #ready' do
it 'rolls the crust, preheats the oven, and uploads the photo' do
roller = double
heater = double
photo_sharing_service = double
pie_crust = PieCrust.new(10, roller: roller, heater: heater, photo_sharing_service: photo_sharing_service)
expect(pie_crust).to_not be_ready
expect(roller).to receive(:roll_out).with(pie_crust)
expect(heater).to receive(:preheat).with_no_arguments
expect(photo_sharing_service).to receive(:upload).with(pie_crust)
pie_crust.prep
expect(pie_crust).to be_ready
end
end
end
I feel like this is OK but it feels like it prescribes and duplicates a lot of the stuff that’s going on in the application code, which doesn’t feel ideal to me but also feels kind of fine.
Is there any other way? There is. I learned this one from my brilliant coworker Máximo Mussini. While looking through a gem he made (Journeyman, a lightweight replacement to FactoryGirl), I discovered some super interesting code, and without his blessing I extracted it out into a gem which I may use to help me write tests in the future. That gem is called stub_constant, and using it, I would revert that code to the first version, avoiding the arguably awkward dependency injection.
You might be assuming: OK, so if you don’t inject the constants, you must load the entire application environment, because you’re going to be depending on those dependencies. Or like, maybe you don’t load the entire application environment, but you must at least load the code which defines those 3 constants, right?
Nope! Doing that is usually really difficult, because once you load the files which define those constants, those files are probably referencing other constants, so you need to load the files which define those constants and now you might as well just load the whole thing…
So… “Whaaaat?” You might say.
Here’s what the constant-referencing isolation tests would look like:
require "stub_constant"
StubConstant.klass(:Oven)
StubConstant.klass(:RollingPin)
StubConstant.module(:Instagram)
RSpec.describe PieCrust do
describe '#prep, #ready?' do
it 'rolls the crust, preheats the oven, and uploads the photo' do
roller = double
expect(RollingPin).to receive(:new).with_no_arguments.and_return(roller)
heater = double
expect(Oven).to receive(:new).with_no_arguments.and_return(heater)
pie_crust = PieCrust.new
expect(pie_crust).to_not be_ready
expect(roller).to receive(:roll_out).with(pie_crust)
expect(heater).to receive(:preheat).with_no_arguments
expect(Instagram).to receive(:upload).with(pie_crust)
pie_crust.prep
expect(pie_crust).to be_ready
end
end
end
Sooo it got even more prescriptive. But it’s a pretty neat way to do a purely isolated test without needing to rewrite your code.
How would you test this? I want to know.