Implementing testable singleton classes in Ruby

What are singleton classes

Singleton is a software design pattern which restricts instances of the class to a set of one, “single” object. By having just a single named object we can provide ourselves easy access to things like shared database connection, application configuration, OS environment, etc.

There’s a fair amount of critique going on around singletons as such. Some radicals consider it to be an anti-pattern, stating that “singletons” are just a fancier name for “globals”. The others rightfully note that despite being super-handy, singletons cause a bunch of problems during creation of tests.

We know that in modern development tests are almost everything. This article offers a practical approach to coding Ruby singleton classes in a transparent and testable way.

TL;DR

You can skip lengthy step-by-step explanations and jump right to the solution.

Drafting it out

Suppose we’re designing a simple Internet client dealing with admin section of our website, deployed at multiple domains. In order to keep our code DRY, we need a “mode object”, accessible from anywhere, implementing the functionality as specified:

  1. Read domain name of our website from DOMAIN= environment variable. This value is required.
  2. Read website language from LANGUAGE= environment variable. If omitted, default to "de".
  3. Read HTTP security setting from INSECURE= environment variable. If omitted, default to secure (HTTPS).
  4. Compute a ready-to-use admin page URL based on Items 1, 2 and 3.
  5. Be sane, raise meaningful errors on missing/incorrect environment variable values.

Implementation requirements

The “mode object” we’re building should:

  1. Be easily accessible from anywhere in the project code, be singleton object.
  2. Evaluate properties upon request and memoize their computed values. Contain no procedural initialization sequences.
  3. Be easy to test by RSpec without major pain or magic.

Just in case, here’s your TL;DR link again.

The “plain module” way

In Ruby all objects of type Module are singletons by definition. So the easiest and the most straightforward way is just to have a module Mode with a number of singleton (self.*) methods in it. Let’s start by adding the language property to mode.rb:

require "active_support/core_ext/object/blank"

module Mode
  # @return [String]
  def self.language
    @language ||= if (s = ENV["LANGUAGE"]).present?
      s
    else
      "de"
    end
  end
end

Now, anywhere in the project we can call Mode.language and get a nice non-blank string defaulting to "de" as specified. As we agreed to be test-driven, let’s back it with some RSpec examples (mode_spec.rb):

RSpec.describe Mode do
  describe ".language" do
    subject { described_class.language }

    context "when default" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
        is_expected.to eq "de"
      end
    end

    context "when set via ENV" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
        is_expected.to eq "fr"
      end
    end
  end
end

, and run our tests:

$ bundle exec rspec spec/lib/mode_spec.rb

Mode
  .language
    when default
      is expected to eq "de"
    when set via ENV
      is expected to eq "fr" (FAILED - 1)

Failures:

  1) Mode.language when set via ENV is expected to eq "fr"
     Failure/Error: is_expected.to eq "fr"

       expected: "fr"
            got: "de"

       (compared using ==)

Finished in 0.01787 seconds (files took 0.13165 seconds to load)
2 examples, 1 failure

No matter in which order the examples are run, one of them always fails. However, they’re okay when being run individually:

$ bundle exec rspec spec/lib/mode_spec.rb:5

Mode
  .language
    when default
      is expected to eq "de"

Finished in 0.00138 seconds (files took 0.12877 seconds to load)
1 example, 0 failures

$ bundle exec rspec spec/lib/mode_spec.rb:12

Mode
  .language
    when set via ENV
      is expected to eq "fr"

Finished in 0.00589 seconds (files took 0.12801 seconds to load)
1 example, 0 failures

I think it’s obvious who the culprit is. @language ||= at line 6 of mode.rb is causing a “global state” side effect.

Intermediate conclusion:

  1. Such singleton module is easy to implement in plain Ruby with no dependencies.
  2. Requirement #3 (“be easy to test”) is not met due to side effect baked into the implementation.

The Singleton way

There’s a Singleton module in Ruby standard library, bundled since version 2.0.0. To be frank, the need of this module, as well as the motives of people using it, are total mystery to me. However, since it’s relevant to our topic, let’s consider it, too.

mode.rb based on Singleton might look like this (for brevity we yet support language property only):

require "active_support/core_ext/object/blank"
require "singleton"

class Mode
  include Singleton

  # @return [String]
  def language
    @language ||= if (s = ENV["LANGUAGE"]).present?
      s
    else
      "de"
    end
  end
end

Line 4 states that Mode is now a class. Which we can’t instantiate though, Singleton has taken it away:

>> Mode.new
Traceback (most recent call last):
        …
        1: from (irb):2
NoMethodError (private method `new' called for Mode:Class)

Let’s proceed with tests (mode_spec.rb):

RSpec.describe Mode do
  describe ".language" do
    subject { described_class.instance.language }   # (1)

    context "when default" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
        is_expected.to eq "de"
      end
    end

    context "when set via ENV" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
        is_expected.to eq "fr"
      end
    end
  end
end

Running it we get a:

$ bundle exec rspec spec/lib/mode_spec.rb

Mode
  .language
    when default
      is expected to eq "de"
    when set via ENV
      is expected to eq "fr" (FAILED - 1)
…
Finished in 0.0178 seconds (files took 0.1335 seconds to load)
2 examples, 1 failure

Intermediate conclusion:

  1. The side effect problem stays, Requirement #3 still not met.
  2. Mode is a “class”, just in case anyone cares.
  3. We have to access object properties via Mode.instance.property (line 3, mark (1)), meaning that Requirement #1 is satisfied quite poorly.

The “compound module” way

Considering the yet unresolved side effect problem, let’s outline the solution that will meet our requirements, but be testable in the first place.

  1. The caller should access properties via Mode.property. Period.
    1. Thus, Mode should be a plain module.
  2. In order to mitigate the side effect problem in tests, properties should be enclosed in a regular object, which we can create and discard as needed.
    1. Thus, Mode property container should be a plain instantiatable class.
  3. There should be a bridge between Mode and its property container instance, implementing the singleton pattern altogether.

Let’s begin with property container class:

require "active_support/core_ext/object/blank"

module Mode
  class Instance
    # @return [String]
    def language
      @language ||= if (s = ENV["LANGUAGE"]).present?
        s
      else
        "de"
      end
    end
  end # Instance
end

, and its testing counterpart (mode_spec.rb):

RSpec.describe Mode::Instance do
  describe "#language" do
    subject { described_class.new.language }

    context "when default" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
        is_expected.to eq "de"
      end
    end

    context "when set via ENV" do
      it do
        expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
        is_expected.to eq "fr"
      end
    end
  end
end

Let’s test the property container:

$ bundle exec rspec spec/lib/mode_spec.rb

Mode::Instance
  #language
    when default
      is expected to eq "de"
    when set via ENV
      is expected to eq "fr"

Finished in 0.00954 seconds (files took 0.12592 seconds to load)
2 examples, 0 failures

Finally! Property logic and tests are okay, now let’s delegate Mode.language calls to an instance of the property container (mode.rb):

require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"

module Mode
  class << self
    delegate :language, to: :instance

    def instance
      @instance ||= Instance.new
    end
  end

  class Instance
    # …
  end
end

Delegation test might look like this:

RSpec.describe Mode do
  describe "delegation" do
    [
      :language,
    ].each do |m|
      describe ".#{m}" do
        it "is delegated" do
          expect(described_class.instance).to respond_to(m)                       # (1)
          expect(described_class.instance).to receive(m).and_return(:signature)   # (1)
          expect(described_class.public_send(m)).to eq :signature                 # (1)
        end
      end
    end
  end
end

Running it we get a:

$ bundle exec rspec spec/lib/mode_spec.rb:1

Mode
  delegation
    .language
      is delegated

Finished in 0.00498 seconds (files took 0.25175 seconds to load)
1 example, 0 failures

Delegation testing listed above might seem a bit redundant, esp. considering that there’s a delegate_method matcher in Shoulda. In fact, lines 8-10, mark (1) ensure that entire delegation chain is not broken. delegate_method alone doesn’t give us that degree of control and often lets silly refactoring mistakes by.

Intermediate conclusion:

  1. We’re able to access object properties via Mode.property, as projected.
  2. We can test property logic easily and without any side effect issues.

Comparing the three

Considering the implementation requirements formulated earlier, let’s compare our three solutions:

“plain module” Singleton-based “compound module” winner
Easy access to properties good poor good “plain” and “compound”
Property memoization good good good all
Testability poor poor good “compound”

Complete solution

Now that we’re confident that the “compound module” solution gives us what we want, let’s finalize it to make Mode fully functional:

require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"

module Mode
  class << self
    delegate *[
      :admin_url,
      :domain,
      :insecure?,
      :language,
      :protocol,
    ], to: :instance

    def instance
      @instance ||= Instance.new
    end
  end

  class Instance
    # Admin page URL.
    # @return [String]
    def admin_url
      @admin_url ||= "#{protocol}://#{domain}/#{language}/admin/"
    end

    # The domain part of the URL.
    # @return [String]
    def domain
      @domain ||= if (s = ENV[vn = "DOMAIN"]).present?
        s
      else
        raise "Environment variable must be set: #{vn}"
      end
    end

    # HTTP protocol security setting.
    # @return [Boolean] Default is <tt>false</tt>.
    def insecure?
      ENV["INSECURE"] == "!"    # (1)
    end

    # Website language.
    # @return [String] Default is <tt>"de"</tt>.
    def language
      @language ||= if (s = ENV["LANGUAGE"]).present?
        s
      else
        "de"
      end
    end

    # Request protocol string.
    # @return [String] Default is <tt>"https"</tt> unless {#insecure?}.
    def protocol
      @protocol ||= if insecure?
        "http"
      else
        "https"
      end
    end
  end # Instance
end

A couple notes on insecure? at line 39, mark (1). Environment variable INSECURE= is treated as “flaggy”. Also property value is not memoized since it’s a boolean. There is a solution to memoize booleans in Ruby, but it’s a bit out of the scope of this article.

Here’s the complete test code:

RSpec.describe Mode do
  describe "delegation" do
    [
      :admin_url,
      :domain,
      :insecure?,
      :language,
      :protocol,
    ].each do |m|
      describe ".#{m}" do
        it "is delegated" do
          expect(described_class.instance).to respond_to(m)                       # (1)
          expect(described_class.instance).to receive(m).and_return(:signature)   # (2)
          expect(described_class.public_send(m)).to eq :signature                 # (3)
        end
      end
    end
  end
end

RSpec.describe Mode::Instance do
  def stub_env(var, value = nil)
    expect(ENV).to receive(:[]).with(var).and_return(value)
  end

  describe "#admin_url" do
    let(:instance) { described_class.new }
    subject { instance.admin_url }

    it do
      expect(instance).to receive(:domain).and_return("site.com")
      expect(instance).to receive(:insecure?).and_return(true)
      expect(instance).to receive(:language).and_return("fr")
      is_expected.to eq "http://site.com/fr/admin/"
    end
  end # describe "#admin_url"

  describe "#domain" do
    subject { described_class.new.domain }

    context "when default" do
      it do
        stub_env("DOMAIN")
        expect { subject }.to raise_error("Environment variable must be set: DOMAIN")
      end
    end

    context "when blank" do
      it do
        stub_env("DOMAIN", " ")
        expect { subject }.to raise_error("Environment variable must be set: DOMAIN")
      end
    end

    context "when set via ENV" do
      it do
        stub_env("DOMAIN", "site.com")
        is_expected.to eq "site.com"
      end
    end
  end # describe "#domain"

  describe "#insecure?" do
    subject { described_class.new.insecure? }

    context "when default" do
      it do
        stub_env("INSECURE")
        is_expected.to eq false
      end
    end

    context "when set via ENV" do
      it do
        stub_env("INSECURE", "!")
        is_expected.to eq true
      end
    end
  end # describe "#insecure?"

  describe "#language" do
    subject { described_class.new.language }

    context "when default" do
      it do
        stub_env("LANGUAGE")
        is_expected.to eq "de"
      end
    end

    context "when set via ENV" do
      it do
        stub_env("LANGUAGE", "fr")
        is_expected.to eq "fr"
      end
    end
  end # describe "#language"
end

Conclusion

Implementing custom singleton classes using the “compound module” approach lets us benefit from easy access while not sacrificing testability.