Paweł Gościcki Paweł Gościcki - 5 months ago 12
Ruby Question

Stubbing/mocking global constants in RSpec

I have a gem, which has a method which acts differently depending on the Rails.env:

def self.env
if defined?(Rails)
Rails.env
elsif ...


And now I'd like to write a spec, which tests this code path. Currently I'm doing it like this:

Kernel.const_set(:Rails, nil)
Rails.should_receive(:env).and_return('production')
...


And it's ok, just feels ugly. Another way is to declare this in
spec_helper
:

module Rails; end


And it works as well. But maybe there is a better way? Ideally this should work:

rails = double('Rails')
rails.should_receive(:env).and_return('production')


But, well, it does not. Or maybe I'm doing something wrong?

Answer

Per the various tweets about this, switching on constants is generally a bad idea because it makes things a bit of a challenge to test and you have to change the state of constants in order to do so (which makes them a little less than constant). That said, if you're writing a plugin that has to behave differently depending on the environment in which it's loaded, you're going to have to test on the existence of Rails, Merb, etc to somewhere, even if it's not in this particular part of the code. Wherever it is, you want to keep it isolated so that decision happens only once. Something like MyPlugin::env. Now you can safely stub that method in most places, and then spec that method by stubbing constants.

As to how to stub the constants, your example doesn't look quite right. The code is asking if defined?(Rails), but Kernel.const_set(:Rails, nil) doesn't undefine the constant, it just sets its value to nil. What you want is something like this (disclaimer - this is off the top of my head, untested, not even run, may contain syntax errors, and is not well factored):

def without_const(const)
  if Object.const_defined?(const)
    begin
      @const = const
      Object.send(:remove_const, const)
      yield
    ensure
      Object.const_set(const, @const)
    end
  else
    yield
  end
end

def with_stub_const(const, value)
  if Object.const_defined?(const)
    begin
      @const = const
      Object.const_set(const, value)
      yield
    ensure
      Object.const_set(const, @const)
    end
  else
    begin
      Object.const_set(const, value)
      yield
    ensure
      Object.send(:remove_const, const)
    end
  end
end

describe "..." do
  it "does x if Rails is defined" do
    rails = double('Rails', :env => {:stuff_i => 'need'})
    with_stub_const(:Rails, rails) do
      # ...
    end
  end

  it "does y if Rails is not defined" do
    without_const(:Rails) do
      # ....
    end
  end
end

I'll give some thought as to whether we should include this in rspec or not. It's one of those things that if we added people would use it as an excuse to rely on constants when they don't need to :)