fp.monkey
fp.monkey

Reputation: 217

Stubbing a module, its methods, and inner classes with RSpec

I have a module like this that I'm trying to write unit tests for

module MyThing
  module Helpers
    def self.generate_archive
      # ...
      ::Configuration.export(arg)
    rescue ::Configuration::Error => error
      raise error
    end
  end
end

The ::Configuration module can't exist in my unit testing environment for reasons that are beyond my control, so I need to stub it out. Here's what I've come up with so far.

RSpec.describe 'MyThing' do
  it 'generates an archive' do
    configuration_stub = stub_const("::Configuration", Module.new)
    configuration_error_stub = stub_const("::Configuration::Error", Class.new)

    expect_any_instance_of(configuration_stub).to receive(:export).with("arg")
    MyThing::Helpers.generate_archive
  end
end

This gets me an error.

NoMethodError:
  Undefined method `export' for Configuration:Module

If I put the configuration_stub definition inline with the expect_any_instance_of like this

RSpec.describe 'MyThing' do
  it 'generates an archive' do
    configuration_error_stub = stub_const("::Configuration::Error", Class.new)

    expect_any_instance_of(stub_const("::Configuration", Module.new)).to receive(:export).with("arg")
    MyThing::Helpers.generate_archive
  end
end

I also get an error.

NameError:
  Uninitialized constant Configuration::Error
...
# --- Caused by: ---
# NoMethodError:
#   Undefined method `export' for Configuration:Module

Upvotes: 0

Views: 1179

Answers (1)

Schwern
Schwern

Reputation: 165446

expect_any_instance_of works on instance methods. export is being called as a class method.

Instead, use a normal expect on the class.

expect(Configuration).to receive(:export).with("arg")

Note: it is not necessary to write ::Configuration in the tests. The :: is to clarify between MyThing::Helpers::Configuration and Configuration.

Note: if you're calling methods directly on Configuration it should probably be a Class not a Module.

Note: instead of calling methods on a class, consider using a Configuration object. App.config.export where App.config returns the default Configuration object. This is more flexible.

The problem with that is RSpec will verify that Configuration.export exists. It doesn't. You could turn off verification, or you could make a real class to test with.

  before {
    stub_const(
      "Configuration",
      Class.new do
        def self.export(*args)
        end
      end
    )
  }

  it 'exports' do
    expect(Configuration).to receive(:export).with("arg")
    Configuration.export("arg")
  end

You could write a stub Configuration module for testing only and put it into spec/support, but your tests are increasingly divorced from reality.

The real problem is your project should have a real Configuration class!

I'm going to guess the real Configuration contains production information which cannot be checked into the repository. This is a common anti-pattern. The Configuration module should hide the details of where the configuration values are coming from. There should be independent development, test, and production configurations. There are many ways of doing this, the most common are to use environment specific config files, or to store environment specific config values in environment variables.


While you could mock the Configuration::Error exception, there should be no reason Configuration::Error cannot exist in your test environment. Add it and simulate an error like so:

  context 'when Configuration.export raises an error'
    before {
      allow(Configuration).to receive(:export).and_raise(Configuration::Error)
    }

    it 'does whatever its supposed to do' do
    end
  end

Upvotes: 1

Related Questions