Testing Code in a Rails Initializer
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:
config.action_controller.asset_host = "assets.example.com"
Eventually, as the security and performance needs of your site change, it may grow to:
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:
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:
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:
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:
config.action_controller.asset_host = AssetHosts.new
Now lets wrap some tests around AssetHosts
. Here’s a first cut:
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:
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.