Testing Code in a Rails Initializer

on

Rails prides itself on sane defaults, but also provides hooks for customizing the framework by providing Ruby blocks in your configuration files. Most of this code begins and ends its life simply and innocuously. Sometimes, however, it grows. Maybe it’s only 3 or 4 lines, but chances are they define important behavior.

Pretty soon, you’re going to want some tests. But while testing models and controllers is a well-established practice, how do you test code that’s tucked away in an initializer? Is there such thing as an initializer test?

No, not really. But that’s ok. Configuration or DSL-style code can trick us into forgetting that we have the full arsenal of Ruby and OO practices at our disposal. Let’s take a look at a common idiom found in initialization code and how we might write a test for it.

Configuring Asset Hosts

Asset host configuration often start as a simple String:

1
config.action_controller.asset_host = "assets.example.com"

Eventually, as the security and performance needs of your site change, it may grow to:

1
2
3
4
5
6
7
8
9
config.action_controller.asset_host = Proc.new do |*args|
  source, request = args

  if request.try(:ssl?)
    'ssl.cdn.example.com'
  else
    'cdn%d.example.com' % (source.hash % 4)
  end
end

Rails accepts an asset host Proc which takes two arguments – the path to the source file and, when available, the request object – and returns a computed asset host. What we really want to test here is not the assignment of our Proc to a variable, but the logic inside the Proc. If we isolate it, it’s going to make our lives a bit easier.

Since Rails seems to want a Proc for the asset host, we can provide one. Instead of embedding it in an environment file, we can return one from a method inside an object:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AssetHosts
  def configuration
    Proc.new do |*args|
      source, request = args

      if request.try(:ssl?)
        'ssl.cdn.example.com'
      else
        'cdn%d.example.com' % (source.hash % 4)
      end
    end
  end
end

It’s the exact same code inside the #configuration method, but now we have an object we can test and refactor. To use it, simply assign it to the asset_host config variable as before:

1
config.action_controller.asset_host = AssetHosts.new.configuration

At this point you may see an opportunity to leverage Ruby’s duck typing, and eliminate the explicit Proc entirely, instead providing an AssetHosts#call method directly. Let’s see how that would work:

1
2
3
4
5
6
7
8
9
class AssetHosts
  def call(source, request = nil)
    if request.try(:ssl?)
      'ssl.cdn.example.com'
    else
      'cdn%d.example.com' % (source.hash % 4)
    end
  end
end

Since Rails just expects that the object you provide for the asset_hosts variable respond to the #call interface (like Proc itself does), you can simplify the configuration:

1
config.action_controller.asset_host = AssetHosts.new

Now lets wrap some tests around AssetHosts. Here’s a first cut:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
describe AssetHosts do
  describe "#call" do
    let(:https_request) { double(ssl?: true) }
    let(:http_request)  { double(ssl?: false) }

    context "HTTPS" do
      it "returns the SSL CDN asset host" do
        AssetHosts.new.call("image.png", https_request).
          should == "ssl.cdn.example.com"
      end
    end

    context "HTTP" do
      it "balances asset hosts between 0 - 3" do
        asset_hosts = AssetHosts.new
        asset_hosts.call("foo.png", http_request).
          should == "cdn1.example.com"

        asset_hosts.call("bar.png", http_request).
          should == "cdn2.example.com"
      end
    end

    context "no request" do
      it "returns the non-ssl asset host" do
        AssetHosts.new.call("image.png").
          should == "cdn0.example.com"
      end
    end
  end
end

It’s not magic, but the beauty of first-class objects is they have room to breathe and help present refactorings. In this case, you can apply the Composed Method pattern to AssetHosts#call.

Guided by tests, you might end up with an object that looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AssetHosts
  def call(source, request = nil)
    if request.try(:ssl?)
      https_asset_host
    else
      http_asset_host(source)
    end
  end

private

  def http_asset_host(source)
    'cdn%d.example.com' % cdn_number(source)
  end

  def https_asset_host
    'ssl.cdn.example.com'
  end

  def cdn_number(source)
    source.hash % 4
  end
end

Since the external behavior of AssetHosts hasn’t changed, no changes to the tests are required.

By making a small leap – isolating configuration code into an object – we now have logic that is easier to test, read, and change. If you find yourself stuck in a similar situation, with important logic stuck in a place that resists testing, see where a similar leap can lead you.

Looking for more about Ruby, code quality, OOP and Rails security? Subscribe to our newsletter.

Comments