Extract Scope: where should the code live?

Peter Aitken Principal Software Engineer, Applications

In Rails, accessing specific records from the database can at times become unnecessarily complex.

The complexity is compounded when this code is jammed in the middle of an already bloated controller action. Naturally this has a knock on effect on the complexity of testing the new functionality.

In these situations, scope is an invaluable tool.

From the Rails docs, scope: “Adds a class method for retrieving and querying objects. The method is intended to return an ActiveRecord::Relation object, which is composable with other scopes.”

A scope has a single responsibility to retrieve data, and on its own is straightforward to test.

By using Extract Scope, you can divide and conquer the current issue of testing the bloated controller action into two parts, each being easier to manage and enabling code reuse and ease of maintenance and testing.

Motivation

Make reuse and testing easier by splitting a problem into smaller parts.

Code Smells

  1. Tests are painful to write due to the current home of the source code.

  2. where being called outside of an ActiveRecord::Model.

In the following example, the admin area of a site has a page that lists the Orders that are ending today, allowing the sales department to reach out to customers whose account is about to lapse.

# app/controllers/admin/ending_orders_controller.rb
class Admin::EndingOrdersController < ApplicationController
  def index
    # ...
    @orders = Order.where(ends_on: Date.today)
    # ...
  end
end
# spec/controllers/admin/ending_orders_controller_spec.rb
module Admin
  RSpec.describe EndingOrdersController, type: :controller do
    describe "#index" do
      before do
        # given a 50 line controller action,
        # imagine a large number of allow() calls and
        # doubles stubbing out the surrounding code.
      end

      # ...

      it "assigns orders ending today" do
        order = Order.create ends_on: Date.today
        get :index
        expect(assigns[:orders]).to include(order)
      end

      # ...
    end
  end
end

Due to the volume of code surrounding the where call:

  • Tests get missed.
  • Tests become hard to write, which discourages developers from writing tests for this code.
  • Code isn’t easily reusable.
  • Code isn’t easily found, which results in similar code being duplicated throughout the codebase.

Mechanics

The mechanics of Extract Scope are a Rails specific implementation of Extract Method/Function from “Refactoring” by Martin Fowler.

  1. Test and create a new scope with an intention revealing name in the ActiveRecord model that encapsulates the functionality of the original where statement, and accepts any necessary parameters.
  2. Replace the original where call with scope.
  3. Potentially, simplify original tests to check interaction (spy/mock) with new, fully tested scope, rather than have unnecessary database interaction.

Step 1: Test and Introduce new scope in Order model

# spec/models/order_spec.rb
RSpec.describe Order, type: :model do
  describe "#ending_on" do
    let(:today)     { Date.new(2021, 11, 20) }
    let(:yesterday) { today - 1.day }
    let(:tomorrow)  { today + 1.day }
    let!(:order)    { Order.create ends_on: end_date }

    context "before today" do
      let(:end_date) { yesterday }
      it "does not return Orders that ended yesterday" do
        expect(Order.ending_on(today)).to be_empty
      end
    end

    context "ending today" do
      let(:end_date) { today }

      it "provides Order" do
        expect(Order.ending_on(today)).to include(order)
      end

      it "provides multiple Orders" do
        second_order = Order.create ends_on: end_date

        expect(Order.ending_on(today)).to include(order)
        expect(Order.ending_on(today)).to include(second_order)
        expect(Order.ending_on(today).count).to eql(2)
      end
    end

    context "after today" do
      let(:end_date) { tomorrow }

      it "does not provide Orders that end tomorrow" do
        expect(Order.ending_on(today)).to be_empty
      end
    end
  end
end
# app/models/order.rb
class Order < ApplicationRecord
  scope :ending_on, ->(end_date) { ends_on: end_date }
end

Step 2: Replace the original where call with scope

Having put the new scope in place, now it is time to start using it in the controller.

# app/controllers/admin/ending_orders_controller.rb
class Admin::EndingOrdersController < ApplicationController
  def index
    # ...
    @orders = Order.ending_on(Date.today)
    # ...
  end
end

At this point the original controller tests should still be passing.

Step 3: Simplify original tests

Having documented the public interface of Order.ending_on with the model specs, a response can reliably be faked (stubbed in this case) in the controller specs, which in turn check the interaction between Controller and Model.

# spec/controllers/admin/ending_orders_controller_spec.rb
module Admin
  RSpec.describe EndingOrdersController, type: :controller do
    describe "#index" do
      # ...
      it "assigns orders ending today" do
        orders = [Order.new]
        allow(Order).to receive(:ending_on).
                          with(Date.today).
                          and_return(orders)
        get :index
        expect(assigns[:orders]).to eql(orders)
      end
      # ...
    end
  end
end

All good unit tests should avoid connecting to the database, however with Rails this is a line that is always crossed. This doesn’t mean there shouldn’t be an effort to minimise this.

In terms of keeping our test suite running fast, it makes sense to invest in this clean up by stubbing Order.ending_on and returning a non-persisted instance of Order.

Things to Consider

1: Clarity of the test

The original controller test was very clear to understand, even if it did not cover all the necessary cases.

In the last code example the stubbing reduces that clarity a little, but not to a significant extent.

Only you can be the best judge of which approach to take in your context.

2: Different approaches

Rather than introducing the Order.ending_on(end_date) method with the end_date parameter, another approach could have avoided the parameter by encapsulating the knowledge of the end_date within the method.

Below the scope is now Order.ending_today, without any parameters. However it is not as flexible, or as easy to test as it can be.

# app/models/order.rb
class Order < ApplicationRecord
  scope :ending_today, -> { ends_on: Date.today }
end

3: Complexity and our example

The example used was fairly simple to aid understanding:

Order.where(ends_on: Date.today)

When a where calls parameters grow in complexity, the number of tests required grow quickly too.

The context within your codebase matters. Treat anywhere you see where as flag to consider Extracting Scope, and decide if this is the time to do it.

Summary

By “Extracting Scope”, we take a divide and conquer approach when seeing where being called outside of an ActiveRecord model. This allows us to:

  1. See the wood from the trees. Moving the code into the model lets us see and test all the necessary cases without the additional baggage of what is happening in the controller action.

  2. Encapsulates knowledge within the Order class.

  3. With the scope living in the appropriate class, it will be easier to find when developers are looking for it, which makes it easier to reuse and maintain.