Resetting a singleton instance in Ruby

风流意气都作罢 提交于 2020-01-20 18:30:35

问题


How do I reset a singleton object in Ruby? I know that one'd never want to do this in real code but what about unit tests?

Here's what I am trying to do in an RSpec test -

describe MySingleton, "#not_initialised" do
  it "raises an exception" do
    expect {MySingleton.get_something}.to raise_error(RuntimeError)
  end
end

It fails because one of my previous tests initialises the singleton object. I have tried following Ian White's advice from this link which essentially monkey patches Singleton to provide a reset_instance method but I get an undefined method 'reset_instance' exception.

require 'singleton'

class <<Singleton
  def included_with_reset(klass)
    included_without_reset(klass)
    class <<klass
      def reset_instance
        Singleton.send :__init__, self
        self
      end
    end
  end
  alias_method :included_without_reset, :included
  alias_method :included, :included_with_reset
end

describe MySingleton, "#not_initialised" do
  it "raises an exception" do
    MySingleton.reset_instance
    expect {MySingleton.get_something}.to raise_error(RuntimeError)
  end
end

What is the most idiomatic way to do this in Ruby?


回答1:


Tough question, singletons are rough. In part for the reason that you're showing (how to reset it), and in part because they make assumptions that have a tendency to bite you later (e.g. most of Rails).

There are a couple of things you can do, they're all "okay" at best. The best solution is to find a way to get rid of singletons. This is hand-wavy, I know, because there isn't a formula or algorithm you can apply, and it removes a lot of convenience, but if you can do it, it's often worthwhile.

If you can't do it, at least try to inject the singleton rather than accessing it directly. Testing might be hard right now, but imagine having to deal with issues like this at runtime. For that, you'd need infrastructure built in to handle it.

Here are six approaches I have thought of.


Provide an instance of the class, but allow the class to be instantiated. This is the most in line with the way singletons are traditionally presented. Basically any time you want to refer to the singleton, you talk to the singleton instance, but you can test against other instances. There's a module in the stdlib to help with this, but it makes .new private, so if you want to use it you'd have to use something like let(:config) { Configuration.send :new } to test it.

class Configuration
  def self.instance
    @instance ||= new
  end

  attr_writer :credentials_file

  def credentials_file
    @credentials_file || raise("credentials file not set")
  end
end

describe Config do
  let(:config) { Configuration.new }

  specify '.instance always refers to the same instance' do
    Configuration.instance.should be_a_kind_of Configuration
    Configuration.instance.should equal Configuration.instance
  end

  describe 'credentials_file' do  
    specify 'it can be set/reset' do
      config.credentials_file = 'abc'
      config.credentials_file.should == 'abc'
      config.credentials_file = 'def'
      config.credentials_file.should == 'def'
    end

    specify 'raises an error if accessed before being initialized' do
      expect { config.credentials_file }.to raise_error 'credentials file not set'
    end
  end
end

Then anywhere you want to access it, use Configuration.instance


Making the singleton an instance of some other class. Then you can test the other class in isolation, and don't need to test your singleton explicitly.

class Counter
  attr_accessor :count

  def initialize
    @count = 0
  end

  def count!
    @count += 1
  end
end

describe Counter do
  let(:counter) { Counter.new }
  it 'starts at zero' do
    counter.count.should be_zero
  end

  it 'increments when counted' do
    counter.count!
    counter.count.should == 1
  end
end

Then in your app somewhere:

MyCounter = Counter.new

You can make sure to never edit the main class, then just subclass it for your tests:

class Configuration
  class << self
    attr_writer :credentials_file
  end

  def self.credentials_file
    @credentials_file || raise("credentials file not set")
  end
end

describe Config do
  let(:config) { Class.new Configuration }
  describe 'credentials_file' do  
    specify 'it can be set/reset' do
      config.credentials_file = 'abc'
      config.credentials_file.should == 'abc'
      config.credentials_file = 'def'
      config.credentials_file.should == 'def'
    end

    specify 'raises an error if accessed before being initialized' do
      expect { config.credentials_file }.to raise_error 'credentials file not set'
    end
  end
end

Then in your app somewhere:

MyConfig = Class.new Configuration

Ensure that there is a way to reset the singleton. Or more generally, undo anything you do. (e.g. if you can register some object with the singleton, then you need to be able to unregister it, in Rails, for example, when you subclass Railtie, it records that in an array, but you can access the array and delete the item from it).

class Configuration
  def self.reset
    @credentials_file = nil
  end

  class << self
    attr_writer :credentials_file
  end

  def self.credentials_file
    @credentials_file || raise("credentials file not set")
  end
end

RSpec.configure do |config|
  config.before { Configuration.reset }
end

describe Config do
  describe 'credentials_file' do  
    specify 'it can be set/reset' do
      Configuration.credentials_file = 'abc'
      Configuration.credentials_file.should == 'abc'
      Configuration.credentials_file = 'def'
      Configuration.credentials_file.should == 'def'
    end

    specify 'raises an error if accessed before being initialized' do
      expect { Configuration.credentials_file }.to raise_error 'credentials file not set'
    end
  end
end

Clone the class instead of testing it directly. This came out of a gist I made, basically you edit the clone instead of the real class.

class Configuration  
  class << self
    attr_writer :credentials_file
  end

  def self.credentials_file
    @credentials_file || raise("credentials file not set")
  end
end

describe Config do
  let(:configuration) { Configuration.clone }

  describe 'credentials_file' do  
    specify 'it can be set/reset' do
      configuration.credentials_file = 'abc'
      configuration.credentials_file.should == 'abc'
      configuration.credentials_file = 'def'
      configuration.credentials_file.should == 'def'
    end

    specify 'raises an error if accessed before being initialized' do
      expect { configuration.credentials_file }.to raise_error 'credentials file not set'
    end
  end
end

Develop the behaviour in modules, then extend that onto singleton. Here is a slightly more involved example. Probably you'd have to look into the self.included and self.extended methods if you needed to initialize some variables on the object.

module ConfigurationBehaviour
  attr_writer :credentials_file
  def credentials_file
    @credentials_file || raise("credentials file not set")
  end
end

describe Config do
  let(:configuration) { Class.new { extend ConfigurationBehaviour } }

  describe 'credentials_file' do  
    specify 'it can be set/reset' do
      configuration.credentials_file = 'abc'
      configuration.credentials_file.should == 'abc'
      configuration.credentials_file = 'def'
      configuration.credentials_file.should == 'def'
    end

    specify 'raises an error if accessed before being initialized' do
      expect { configuration.credentials_file }.to raise_error 'credentials file not set'
    end
  end
end

Then in your app somewhere:

class Configuration  
  extend ConfigurationBehaviour
end



回答2:


I guess simply do this will fix your problem:

describe MySingleton, "#not_initialised" do
  it "raises an exception" do
    Singleton.__init__(MySingleton)
    expect {MySingleton.get_something}.to raise_error(RuntimeError)
  end
end

or even better add to before callback:

describe MySingleton, "#not_initialised" do
  before(:each) { Singleton.__init__(MySingleton) }
end



回答3:


To extract a TL;DR from the nice longer answer above, for future lazy visitors like me - I found this to be clean and easy:

If you had this before

let(:thing) { MyClass.instance }

Do this instead

let(:thing) { MyClass.clone.instance }


来源:https://stackoverflow.com/questions/12127336/resetting-a-singleton-instance-in-ruby

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!