9 November 2022
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…
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 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
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
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
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.
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
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
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:
ActiveRecord::Relation
ActiveRecord::AssociationRelation
ActiveRecord::Associations::CollectionProxy
Array
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
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
# 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.
# 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.
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:
#==
when possible#eql?
when converting an active record relation to an array.equal?
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.
#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.
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