Testing Singletons in Ruby
Ruby’s standard library includes the Singleton module, which lets you easily implement the singleton pattern. It can make unit testing a little difficult, though, because you cannot easily instantiate different versions of the class. This post will look at three solutions, two of which are hackish/wrong, and one of which is basic and clean (imho).
The Problem Let’s say we have a configuration class which reads some data from a YAML file and stores global state about the application (and you’ve decided to use a singleton). For this contrived example, we can specify our domain name (“foo.com”), and whether we want to use SSL. We’d like to unit test the Configuration#website method, to make sure it properly prepends either http:// or https://, and that might look something like this:
require 'rspec'
require 'singleton'
require 'yaml'
class Configuration
include Singleton
def initialize
@data = YAML.load_file("config.yml")
end
def website
protocol = @data.fetch(:use_ssl, false) ? "https" : "http"
"#{protocol}://#{@data[:domain]}"
end
end
describe Configuration do
describe "website" do
it "defaults to http" do
file_config = {:domain => "foo.com"}
YAML.stub(:load_file).and_return(file_config)
Configuration.instance.website.should == "http://foo.com" # Passes
end
it "uses http if use_ssl is false" do
file_config = {:domain => "bar.com", :use_ssl => false}
YAML.stub(:load_file).and_return(file_config)
Configuration.instance.website.should == "http://bar.com" # Fails
end
it "uses https if use_ssl is true" do
file_config = {:domain => "baz.com", :use_ssl => true}
YAML.stub(:load_file).and_return(file_config)
Configuration.instance.website.should == "https://baz.com" # Fails
end
end
end
For the above code, the first test passes, and the 2nd and 3rd fail with “got: http://foo.com”. The problem is that we want to test different initialization values (the YAML file), but only one instance of the object is ever created (per the singleton pattern).
Solution #1 (worst) You can dig into the source code for Singleton, and monkey with its internals. Probably, this means either resetting the class’s @__instance__ (@singleton__instance__ in 1.9), or calling something like Singleton.send(:__init__, Configuration). Both of these will work, but are really hackish and brittle. Still, they do work:
class Configuration
include Singleton
def initialize
@data = YAML.load_file("config.yml")
end
def website
protocol = @data.fetch(:use_ssl, false) ? "https" : "http"
"#{protocol}://#{@data[:domain]}"
end
end
describe Configuration do
describe "website" do
it "defaults to http" do
file_config = {:domain => "foo.com"}
YAML.stub(:load_file).and_return(file_config)
Configuration.instance_variable_set("@__instance__", Configuration.send(:new))
Configuration.instance.website.should == "http://foo.com" # Passes
end
it "uses http if use_ssl is false" do
file_config = {:domain => "bar.com", :use_ssl => false}
YAML.stub(:load_file).and_return(file_config)
Configuration.instance_variable_set("@__instance__", Configuration.send(:new))
Configuration.instance.website.should == "http://bar.com" # Passes
end
it "uses https if use_ssl is true" do
file_config = {:domain => "baz.com", :use_ssl => true}
YAML.stub(:load_file).and_return(file_config)
Singleton.send(:__init__, Configuration)
Configuration.instance.website.should == "https://baz.com" # Passes
end
end
end
Solution #2 (Not Great) A more common approach to the problem is to separate the functionality you want to test from the fact that it’s a singleton. Something along these lines:
class Configuration
def initialize
@data = YAML.load_file("config.yml")
end
def website
protocol = @data.fetch(:use_ssl, false) ? "https" : "http"
"#{protocol}://#{@data[:domain]}"
end
end
class SingletonConfiguration < Configuration
include Singleton
end
This is handy for testing, and maybe you can come up with names for the classes that make sense. Unfortunately, though, it means you are not actually testing the class that you’ll be using in your application, which would be nice.
Solution #3 (Better) For my use case, the solution that made the most sense was to clone the class, and then run tests against the clone. By using this pattern, you’re actually testing the class that will be used. Because it’s a clone, it should be a fair representation of your real-world class. The code:
class Configuration
include Singleton
def initialize
@data = YAML.load_file("config.yml")
end
def website
protocol = @data.fetch(:use_ssl, false) ? "https" : "http"
"#{protocol}://#{@data[:domain]}"
end
end
describe Configuration do
describe "website" do
before(:each) do
@configuration_class = Configuration.clone
end
it "defaults to http" do
file_config = {:domain => "foo.com"}
YAML.stub(:load_file).and_return(file_config)
@configuration_class.instance.website.should == "http://foo.com" # Passes
end
it "uses http if use_ssl is false" do
file_config = {:domain => "bar.com", :use_ssl => false}
YAML.stub(:load_file).and_return(file_config)
@configuration_class.instance.website.should == "http://bar.com" # Passes
end
it "uses https if use_ssl is true" do
file_config = {:domain => "baz.com", :use_ssl => true}
YAML.stub(:load_file).and_return(file_config)
@configuration_class.instance.website.should == "https://baz.com" # Passes
end
end
end