DEV Community

Konnor Rogers
Konnor Rogers

Posted on • Edited on

Testing scopes with Rails

The Problem

A common problem I've seen and that took me a long time to understand was how to properly test Rails scopes. I have searched online many times for how to properly tests scopes and find a bunch of old stackoverflow posts which really don't address the issue.

Why are they hard to test?

Scopes can be hard to test because generally you're operating on an entire database which if you include a large number of fixtures can cause global data to leak into your test and cause tests to fail intermittently.

The naive approach

Initially, I thought to "stub" out the ActiveRecord::Relation that scopes tend to operate on, but I found stubbing out the data to be error prone, rigid, and hard to get right.

The approach that operates on a known dataset

After fighting with this for so long, I finally came to a realization. Attach the scope to a known dataset and use that for the test case! But how do we do this? The easiest way is to create a set of records, and then pass their id into a where() query on the model. Let's look at an example.

Let's say we have a model called User and we want to have a scope for both newest and oldest users.

If you're like me, you never know the difference between ASC and DESC for datetime columns.

So lets add our scopes quick and then see if we get it right:

class User < ActiveRecord::Base
  scope :newest, -> { order(created_at: :asc) }
  scope :oldest, -> { order(created_at: :desc) }
end
Enter fullscreen mode Exit fullscreen mode

Now, how could we test it?

Well, in our model test we should first setup a few users.

class UserTest < ActiveSupport::TestCase
  def setup
    @user_one = User.create!(created_at: 1.day.ago)
    @user_two = User.create!(created_at: 2.days.ago)
    @user_three = User.create!(created_at: 3.days.ago)

    # This lets us have a known set of data. We don't have a polluted global scope of users.
    @users = User.where(id: [@user_one.id, @user_two.id, @user_three.id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Now that we have a known set of data, we chain off of it and test our scope.

class UserTest < ActiveSupport::TestCase
  def setup
    # ...
  end

  test "Newest users and oldest users should be sorted properly" do
    # By testing both newest and oldest, we're coupling these tests together
    # We test both just in case our records just happen to be returned in the correct order.
    # we could use reverse_scope to test newest, but instead we just make this explicit.

    # chain off of @users so we use a known set of data rather than the whole database.
    newest_users = @users.newest
    assert_operator newest_users.first.created_at, :>=, newest_users.second.created_at
    assert_operator newest_users.first.created_at, :>=, newest_users.third.created_at
    assert_operator newest_users.second.created_at, :>=, newest_users.third.created_at

    oldest_users = @users.oldest
    assert_operator oldest_users.first.created_at, :<=, oldest_users.second.created_at
    assert_operator oldest_users.first.created_at, :<=, oldest_users.third.created_at
    assert_operator oldest_users.second.created_at, :<=, oldest_users.third.created_at
  end
end
Enter fullscreen mode Exit fullscreen mode

On running this test you should get something like this:

ruby user_test.rb

# Running:

F

Finished in 0.013149s, 76.0489 runs/s, 76.0489 assertions/s.

  1) Failure:
UserTest#test_Newest_users_and_oldest_users_should_be_sorted_properly [single_line_active_record.rb:52]:
Expected 2022-04-05 18:15:24.185615 UTC to be >= 2022-04-06 18:15:24.184933 UTC.
Enter fullscreen mode Exit fullscreen mode

Oops! It failed...perhaps we got the column direction wrong? Let's try this:

class User < ActiveRecord::Base
-  scope :newest, -> { order(created_at: :asc) }
+  scope :newest, -> { order(created_at: :desc) }


-  scope :oldest, -> { order(created_at: :desc) }
+  scope :oldest, -> { order(created_at: :asc) }
end
Enter fullscreen mode Exit fullscreen mode

Then test it again:

ruby user_test.rb

# Running:

.

Finished in 0.021307s, 46.9331 runs/s, 281.5983 assertions/s.

1 runs, 6 assertions, 0 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

Yayyy we did it!!

Adding another scope

Alright, the first scope test may not have been too useful for this limited scope, so lets make an exclusionary scope that checks for users created after a certain date / time.

The scope

class User < ApplicationRecord
  scope :created_after, ->(date) { where("created_at >= ?", date) }
end
Enter fullscreen mode Exit fullscreen mode

The test

class UserTest < ActiveSupport::TestCase
  def setup
    # ...  
  end

  test "Should only show users created from 2 days ago and later" do
    # Always use beginning of day. I believe 2.days.ago drops the "time" causing it to act like
    # #end_of_day which will cause our user created "2.days.ago" to be excluded.
    users = @users.created_after(2.days.ago.beginning_of_day)

    # We know there should only be 2 users created within the last 2 days.
    assert_equal users.size, 2

    assert_includes users, @user_one
    assert_includes users, @user_two
  end
end
Enter fullscreen mode Exit fullscreen mode

Touchdown

$ ruby user_test.rb

# Running:

..

Finished in 0.024298s, 82.3114 runs/s, 452.7127 assertions/s.

2 runs, 11 assertions, 0 failures, 0 errors, 0 skips
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Now is this the first use case of checking created_at useful for this type of limited scope testing? Maybe not. For this particular test of testing creation time, it shouldn't matter because we expect the sorting algorithm to get it right. However, using this method of a limited scope of data makes it easier to start testing more advanced scopes and scopes where data from other tests / fixtures may leak in and its much easier to test results. It also works great on exlusionary scopes / queries like we did with our created_after scope.

Recreation

A gist can be found here of recreating this testing in a single file:

https://gist.github.com/konnorrogers/f59d02bed10308c9ca60a43c87de26d9

Top comments (2)

Collapse
 
iagopuccini profile image
Iago Puccini

Thank you so much for your contribution đź‘Źđź‘Źđź‘Ź

Collapse
 
stefanvermaas profile image
Stefan Vermaas

I love this approach. It doesn’t include any “hacks” with “deleting all data” and it’s very much straightforward! Thanks Konnor for sharing!