I was recently working with an ActiveRecord class that exposed some attributes retrieved from a remote API, rather than from the database. The rules for handling the remote attributes were as follows:
- If the record is unsaved, return the local value of the attribute, even if it's nil.
- If the record is saved and we don't have a local value, call the remote API and remember and return the value.
- If the record is saved and we already have a local value, return that.
Here's the original code (names changed to protect the innocent):
-
class MyModel <ActiveRecord::Base
-
attr_writer :foo, :bar
-
-
def foo
-
(new_record? || @foo) ? @foo : remote_object.foo
-
end
-
-
def bar
-
(new_record? || @bar) ? @bar : remote_object.bar
-
end
-
-
def remote_object
-
@remote_object ||= RemoteService.remote_object
-
end
-
end
The remote_object
method makes a call to the remote service, and memoises the returned object (which contains all the attributes we are interested in).
I didn't really like the duplication in all these accessor methods – we had more than the two I've shown here – so decided to factor it out into a common remote_attr_reader
class method. Originally I had the method take a block which returned the remote value, but that made the tests more complicated, so I ended up using convention over configuration and having the accessor for foo call a remote_foo method.
Here's the new code in the model:
-
class MyModel <ActiveRecord::Base
-
remote_attr_reader :foo, :bar
-
-
def remote_foo
-
remote_object.foo
-
end
-
-
def remote_bar
-
remote_object.bar
-
end
-
-
def remote_object
-
@remote_object ||= RemoteService.remote_object
-
end
-
end
Here's the RemoteAttrReader
module that makes it possible:
-
module RemoteAttrReader
-
def remote_attr_reader *names
-
names.each do |name|
-
attr_writer name
-
define_method name do
-
if new_record? || instance_variable_get("@#{name}")
-
instance_eval "@#{name}"
-
else
-
instance_eval "remote_#{name}"
-
end
-
end
-
end
-
end
-
end
To make the module available to all models, I added an initialiser containing this line:
-
ActiveRecord::Base.send :extend, RemoteAttrReader
Here's the spec for the module:
-
require File.dirname(__FILE__) + '/../spec_helper'
-
-
class RemoteAttrReaderTestClass
-
extend RemoteAttrReader
-
remote_attr_reader :foo
-
-
def remote_foo
-
"remote value"
-
end
-
end
-
-
describe RemoteAttrReader do
-
let(:model) { RemoteAttrReaderTestClass.new }
-
-
describe "for an unsaved object" do
-
before do
-
model.stub(:new_record?).and_return true
-
end
-
-
describe "When the attribute is not set" do
-
it "returns nil" do
-
model.foo.should be_nil
-
end
-
end
-
-
describe "When the attribute is set" do
-
before do
-
model.foo = "foo"
-
end
-
-
it "returns the attribute" do
-
model.foo.should == "foo"
-
end
-
end
-
end
-
-
describe "for a saved object" do
-
before do
-
model.stub(:new_record?).and_return false
-
end
-
-
describe "When the attribute is set" do
-
before do
-
model.foo = "foo"
-
end
-
-
it "returns the attribute" do
-
model.foo.should == "foo"
-
end
-
end
-
-
describe "When the attribute is not set" do
-
it "returns the result of calling remote_<attribute>" do
-
model.foo.should == "remote value"
-
end
-
end
-
end
-
end
To simplify testing of the model, I created a matcher, which I put into a file in spec/support
:
-
class ExposeRemoteAttribute
-
def initialize attribute
-
@attribute = attribute
-
end
-
-
def matches? model
-
@model = model
-
return false unless model.send(@attribute).nil?
-
model.send "#{@attribute}=", "foo"
-
return false unless model.send(@attribute) == "foo"
-
model.stub(:new_record?).and_return false
-
return false unless model.send(@attribute) == "foo"
-
model.send "#{@attribute}=", nil
-
model.stub("remote_#{@attribute}").and_return "bar"
-
model.send(@attribute) == "bar"
-
end
-
-
def failure_message_for_should
-
"expected #{@model.class} to expose remote attribute #{@attribute}"
-
end
-
-
def failure_message_for_should_not
-
"expected #{@model.class} not to expose remote attribute #{@attribute}"
-
end
-
-
def description
-
"expose remote attribute #{@attribute}"
-
end
-
end
-
-
def expose_remote_attribute expected
-
ExposeRemoteAttribute.new expected
-
end
Testing the model now becomes a simple case of testing the remote_ methods in isolation, and using the matcher to test the behaviour of the remote_attr_reader call(s).
-
require File.dirname(__FILE__) + '/../spec_helper'
-
-
describe MyModel do
-
it { should expose_remote_attribute(:name) }
-
it { should expose_remote_attribute(:origin_server) }
-
it { should expose_remote_attribute(:delivery_domain) }
-
-
describe "reading remote foo" do
-
# test as a normal method
-
end
-
end
Technorati Tags: ruby, rails, activerecord, metaprogramming, rspec, matcher, refactoring, dry