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_" 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
[tags]ruby,rails,activerecord,metaprogramming,rspec,matcher,refactoring,dry[/tags]




