rubytestingrspecmockingthor

Stub a method on a Thor CLI


I have a Thor class that has a getter method defined like this:

# Playgrounds CLI skeleton class.
# Commands are added from commands folder
class CLI < Thor
  def self.exit_on_failure?
    true
  end

  no_commands do 
    def location
      unless @location
        path = Location.detect Dir.pwd
        throw 'Could not find a playgrounds directory' if path.nil?
        @location = Location.new path, File.join(path, '.templates')
      end 
      @location 
    end
  end
end

I am now trying to write a unit test for this class that checks that the wiring is correct (commands correctly hand off to the location instance).

require_relative '../lib/cli'
require_relative '../lib/location'

describe CLI do

  subject (:cli) { described_class.new }
  let (:location) { instance_double(Location) }

  before :each do
    allow(Location).to receive(:detect).and_return('')    
    allow(cli).to receive(:location).and_return(location)
  end

  describe 'playground new NAME' do
    it 'uses the specified name' do
      cli.invoke(:new_playground, ['example_playground'])
      expect(location).to have_received(:new_playground)
    end
  end
end

But the CLI always calls the actual location method, not the stubbed-out one. I think this is because Thor actually creates a new instance, so it doesn't use cli at all.

Based on this, I have tried to replace allow(cli) with

allow_any_instance_of(CLI).to receive(:location).and_return location

This works, but it also produces this rather ugly warning on my console:

[WARNING] Attempted to create command "location" without usage or description. Call desc if you want this method to be available as command or declare it inside a no_commands{} block. Invoked from "/vagrant/vendor/bundle/ruby/3.4.0/gems/rspec-mocks-3.13.5/lib/rspec/mocks/any_instance/recorder.rb:223:in 'Module#alias_method'".

How can I wrap allow_any_instance_of in a Thor no_commands block so that it suppresses the warning? The obvious - CLI.no_commands { allow_any_instance_of ... } doesn't work.


Solution

  • I was able to solve it by adding this:

    before do
      ctx = self
      described_class.class_exec do
        no_commands { ctx.allow_any_instance_of(self).to ctx.receive(:location).and_return ctx.location }
      end
    end
    
    after do
      described_class.class_exec do
        no_commands { RSpec::Mocks.teardown }
      end
    end
    

    Explanation, for posterity:

    The key here is to execute the stubbing within the context of the described_class, which allows me to run a no_commands block. Inside that block, I can then do the actual stubbing. Since class_exec changes self, I need to preserve the old self for access to the required methods.

    This is almost enough to suppress the warnings, but not quite: After each test, RSpec automatically restores the old methods, and this causes another warning. Therefore, I added an after block, where I do the same thing to manually restore the mocks.