9 November 2022

Testing Rails Scopes

Note: I saw that Konnor Rogers wrote an article about scopes based on the same observations. No recent online resources show how to test scopes properly. I'm happy to know we drew similar conclusions.

Array and ActiveRecord::Relation enter a test…

Summary

Testing scopes means testing equality against different collection types, and two options are available. Testing with #== assertions on different collection types or converting everything to arrays and testing with #== or #eql? assertions.

Use Model.where(id: records).scope_tested to control the subset of records under test fully. This strategy works with both factories and fixtures.

Finally, here is the simplest way to test an active_record relation against an array of records with RSpec and Minitest.

actual_scope = Model.where(id: records).scope_under_test
expected_records = [post1, post2]

# RSpec
expect(actual_scope).to eq(expected_records) # ordered collection
expect(actual_scope).to contain_exactly(*expected_records) # unordered collection

# Minitest
assert_equal expected_records, actual_scope # ordered collection
assert_equal expected_records.sort, actual_scope.sort # unordered collection

The example

The example consists of a Post model with two database attributes and a few scopes defined. Let's find out how we would test .selectable and .sort_by_title scopes in both RSpec and Minitest.

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.boolean :published, default: false
    t.string :title, null: false
  end
end

class Post < ActiveRecord::Base
  scope :sort_by_title, ->(direction) { order(title: direction) }
  scope :published, -> { where(published: true) }

  def self.selectable(include_ids: nil)
    case include_ids
    when nil
      published
    when ActiveRecord::Relation
      published.or(where(id: include_ids))
    else
      published.or(where(id: Array.wrap(include_ids)))
    end
  end
end

Minitest

Here is the Minitest version testing both ordered and unordered collections returned from scopes.

# program_test.rb
require_relative 'program'
require 'minitest/autorun'

class TestPostScopes < Minitest::Test
  def test_returns_published_posts_and_any_included_posts
    post1 = Post.create(title: 'post a', published: false)
    post2 = Post.create(title: 'post b', published: true)

    posts = Post.where(id: [post1, post2])

    # Testing unordered scope
    assert_equal [post2].sort,        posts.selectable.sort
    assert_equal [post1, post2].sort, posts.selectable(include_ids: post1).sort
    assert_equal [post2].sort,        posts.selectable(include_ids: post2).sort
  end

  def test_returns_ordered_posts_by_title
    post1 = Post.create(title: 'post a', published: false)
    post2 = Post.create(title: 'post b', published: true)

    posts = Post.where(id: [post1, post2])

    # Testing ordered scope
    assert_equal [post2, post1], posts.sort_by_title(:desc)
    assert_equal [post1, post2], posts.sort_by_title(:asc)
  end
end

RSpec

Here is the RSpec version testing both ordered and unordered collections returned from scopes.

require_relative 'program'

RSpec.describe Post do
  let(:post1) { Post.create(title: 'post 1', published: false) }
  let(:post2) { Post.create(title: 'post 2', published: true) }

  describe '.selectable' do
    it 'returns published posts and any included posts' do
      posts = Post.where(id: [post1, post2])

      # Testing unordered scope
      expect(posts.selectable).to contain_exactly(post2)
      expect(posts.selectable(include_ids: post1)).to contain_exactly(post1, post2)
      expect(posts.selectable(include_ids: post2)).to contain_exactly(post2)
    end
  end
  
  describe '.sort_by_title' do
    it 'returns the posts ordered by title descending' do
      posts = Post.where(id: [post1, post2])

      # Testing ordered scope
      expect(posts.sort_by_title(:desc)).to eq [post2, post1]
      expect(posts.sort_by_title(:asc)).to eq [post1, post2]
    end
  end
end

Filtering records with where(id: [])

Whether the starting active_record relation under test returns all the records or only a few don't matter.

Post.where(id: posts) gives complete control of the records which we wish to test the scope. This strategy can work with both fixtures and factories.

Fixtures

With fixtures, it will prevent destroying all the records except the ones we need. We can filter the relevant records and test our scope only on these.

it 'returns published posts' do
  assert_equal [posts(:two)], Posts.where(id: posts(:one, :two)).selectable
end

Factories

With factories, it will prevent creating records in a before {} or using let!. Referencing them first in Post.where(id: posts) will create records on demand, it will remove indirections in our spec and improve its readability.

let(:post1) { Post.create(title: 'post 1', published: false) }
let(:post2) { Post.create(title: 'post 2', published: true) }

it 'returns published posts' do
  expect(Post.where(id: [post1, post2]).selectable).to contain_exactly(post2)
end

What are scopes anyway?

Scopes are chainable methods that return active record relations.

Testing scopes often means testing an ActiveRecord::Relation against other types of collections. There are four classes to know when dealing with collections of records:

Here is a test to illustrate which is which:

def test_class
  post = Post.create(title: 'post a', published: false)
  post.comments.create

  # Model
  assert ActiveRecord::Relation                      === Post.all
  assert Array                                       === Post.all.records
  # Associations
  assert ActiveRecord::Associations::CollectionProxy === post.comments
  assert ActiveRecord::AssociationRelation           === post.comments.all
  assert ActiveRecord::Relation                      === post.comments.all
  assert Array                                       === post.comments.records
end

¿¿¿ ==, eql?, equal? ???

Testing scopes are tricky because two assertions are going on simultaneously: the equality of the collection and the equality of collection items.

The RSpec documentation sums up the different equality assertions pretty well

  == eql? equal?
RSpec .to eq() .to eql() .to equal OR .to be()
Minitest assert_equal   assert_same

Table of equality assertions in RSpec and Minitest

ActiveRecord::Relation#==

API definition

# File activerecord/lib/active_record/relation.rb, line 766
def ==(other)
  case other
  when Associations::CollectionProxy, AssociationRelation
    self == other.records
  when Relation
    other.to_sql == to_sql
  when Array
    records == other
  end
end

We can observe that an active_record relation #== method handles the four collection classes mentioned above.

ActiveRecord::Core#==

API definition

# File activerecord/lib/active_record/core.rb, line 580
def ==(comparison_object)
  super ||
    comparison_object.instance_of?(self.class) &&
    !id.nil? &&
    comparison_object.id == id
end

# Fun facts

Post.find(post.id) == Post.new(id: post.id) 
# => true
Post.find(post.id).eql? Post.new(id: post.id) 
# => true

It's worth noting that, at the record level, #eql? and #== are aliases. Additionally, :id is the only attribute used to match against two records.

Relation & Array Equivalence

An active_record relation is equivalent to an array when it returns the same collection of records. Here are a few experiments to understand equivalences better.

All the assertions below are true.

def test_assertions
  # setup
  post1 = Post.create(title: 'post 1', published: false)
  post2 = Post.create(title: 'post 2', published: true)
  expected_array = [post1, post2]
  actual_scope = Post.where(id: [post1, post2]).order(:title)

  # scope vs array
  assert actual_scope == expected_array
  refute actual_scope.eql?(expected_array) # <- too bad
  refute actual_scope.equal?(expected_array)

  # scope.to_a vs array
  assert actual_scope.to_a == expected_array
  assert actual_scope.to_a.eql?(expected_array)
  refute actual_scope.to_a.equal?(expected_array)

  # scope vs scope
  equivalent_scope = Post.order(:title).all

  refute actual_scope == equivalent_scope # <- matched through #to_sql
  refute actual_scope.eql?(equivalent_scope) # <- matched through #to_sql
  refute actual_scope.equal?(equivalent_scope)

  # saved record vs new record
  assert Post.find(post1.id) == Post.new(id: post1.id)
  assert Post.find(post1.id).eql?(Post.new(id: post1.id))
  refute Post.find(post1.id).equal?(Post.new(id: post1.id))

  # saved record vs saved record
  assert Post.find(post1.id) == post1
  assert Post.find(post1.id).eql?(post1)
  refute Post.find(post1.id).equal?(post1)
end

Here are some of the conclusions we can draw from the results:

Testing ordered and unordered scopes

Testing unordered collections requires sorting the array before asserting equality, as the collections could contain the same number of records in a different order. Calling .sort on an ActiveRecord::Relation returns an Array which becomes handy as we can now use both #== OR #eql? method to test their equality.

On the other hand, it is essential to avoid sorting ordered collections because the order is part of the expected result. In this case, we can use #== out of the box OR call #records / #to_a on the relation to return an array and use #eql? too.

Be careful when testing with #to_sql

We observed previously that ActiveRecord::Relation#== does test equality of two scopes with #to_sql.

Testing #to_sql doesn't hit the database and is, therefore, faster to run. We could think of testing scopes this way:

def test_to_sql
  assert_equal <<~SQL.squish, Post.order_by_preference.to_sql
    SELECT "posts".* FROM "posts" ORDER BY "posts"."title" ASC
  SQL
end

Testing raw SQL might be enough for a straightforward query, but testing complex queries this way can be tricky. We're coupled to a SQL implementation and aren't testing the outcome: the collection returned. Changing the scope will mean changing the corresponding test, which prevents proper refactoring.

Final word

Suppose we're looking for a universal solution independent from testing frameworks, use #== assertions and convert every collection to arrays. Converting to arrays hinders readability, but nothing stops us from creating custom assertions/matchers.

Here is an example of a custom matcher and test examples in Minitest

require_relative "program"
require "minitest/autorun"

require 'minitest/assertions'

module Minitest::Assertions
  def assert_scope(expected, actual)
    assert_scope_equal(expected, actual, with_order: false)
  end

  def assert_scope_order(expected, actual)
    assert_scope_equal(expected, actual, with_order: true)
  end

  private

  def assert_scope_equal(expected, actual, with_order: false)
    act = actual.to_a
    exp = expected.to_a
    msg = "Ordered collections aren't equal"

    unless with_order
      act = act.sort
      exp = exp.sort
      msg = "Unordered collections aren't equivalent"
    end

    assert_equal exp, act, msg
  end
end

class PostScopesWithMatchers < Minitest::Test
  def test_returns_published_posts_and_any_included_posts
    post1 = Post.create(title: 'post a', published: false)
    post2 = Post.create(title: 'post b', published: true)

    posts = Post.where(id: [post1, post2])

    assert_scope [post2],        posts.selectable, 
    assert_scope [post1, post2], posts.selectable(include_ids: post1)
    assert_scope [post2],        posts.selectable(include_ids: post2)
  end

  def test_returns_ordered_posts_by_title
    post1 = Post.create(title: 'post a', published: false)
    post2 = Post.create(title: 'post b', published: true)

    posts = Post.where(id: [post1, post2])

    assert_scope_order [post2, post1], posts.sort_by_title(:desc)
    assert_scope_order [post1, post2], posts.sort_by_title(:asc)
  end
end