Hardscrabble 🍫

By Max Jacobson

See also: the archives and an RSS feed

stubbing constants in ruby tests

July 20, 2015

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.

Note: I don't have comments or analytics on this website, so it's hard to tell if people are reading or enjoying it. Please feel free to share any feedback or thoughts by shooting me an email or tagging me in a post on Mastodon @maxjacobson@mastodon.online.