rubyrspec

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?


Solution

  • 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.constantize
          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 :)