← recent

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