The best way to test model scopes in Rails

Learn about Rails scopes and how to best test them with both Rspec and Minitest

Let’s talk about testing model scopes in Rails! Like so many things in web development there are a lot of different ways to approach an issue, and people have lots of different opinions on the value of testing scopes and their approach to testing scopes.

After discussing what scopes are, I will share why I think they are worth testing and how I go about testing Rails’ model scopes, including examples in both Rspec and Minitest.

What are model scopes in Rails

In Rails we have the notion of models, there are a few different ways of thinking about what a model is, some folks like to think of it as the code representation of the data in your database, some like to think of it as an abstraction of business logic.

For this article, let’s go with a model being an abstraction of business logic. If our Rails application was all about books, you might reasonably expect there to be a Book model. In here we will hold everything to do with a book.

There may be attributes, like language, or publish_year, and there may be methods like mark_as_read. These are things you could reasonably ask about a book or to do to a book.

Scopes come in when we’re thinking about a collection of our model, sticking with the book analogy we might want to get a list of long books, or short books, maybe books written for children or written in irish.

In each of these cases, we can assume that any number of books might come back, it isn’t just a single book.

In Rails, if we wanted books written in Irish, we might do;

Book.where(language: 'irish')

This is perfectly fine code but there are some issues, which, spoiler alert, scopes will fix.

  • We haven’t named this logic. When we write a bare query like this, we are leaving it up to the reader to understand that what we are looking for here is books written in Irish. Now in this example that might be apparent, but with more complex queries, the meaning can get lost.
  • We’ve make it hard to change in the future. If our system changes from using ‘irish’ to using ‘ga’, then we need to potentially update several different files. This means that code that should never have cared about what makes a language “Irish” suddenly needs to.
  • We’ve used a lot of characters1. As we start to include other methods, like limit or another where we will get a longer and longer line, making it harder to scan.

Scopes are named, custom queries that define inside of your model, inside our Book model we could have;

scope :in_irish, -> { where(language: 'irish') }

Then anywhere we wanted to bring back books written in Irish we could call Book.in_irish.

This solves the above three issues because;

  • We’ve named the logic, now callers don’t need to worry about what defines a book as being Irish, just that it is in_irish.
  • This is easy to change in the future because we update the scope, but all callers stay the same.
  • We’ve reduced the amount of characters used throughout the application when thinking about books in Irish.

Why test Rails model scopes

Some people believe you should aim for 100% test coverage in your application, that means that every bit of application code is exercised by a test. I don’t subscribe to needing 100% test coverage, but I do think scopes are often worth testing.

Scopes encapsulate something interesting about a model in the system, and their correctness should be proven as a foundation for the other work your system will be doing.

For example, if we had a section of our website that displayed the most recent Irish book, if we didn’t want to test the scope, we might write a test that says something like;

expect(@irish_books.first.language).to eq('irish')

If this test passes, it tells us nothing about if the second book was also written in Irish.

If this test fails, we have no idea if it is because the book was indeed an Irish book, and our test needs to be updated to better show what it means to be Irish, or is it because the definition of being an Irish book has stayed the same but we’ve set @irish_books to something incorrect.

If instead we had a test for the scope, and we know that @irish_books is calling that scope and nothing more, then there is no need for this test of one element of our collection.

How to test Rails model scopes

There are two main schools of thought on how to test these scopes, one is to test the result of the scope, the other is to test what the scope is doing under the hood.

To test the result, you add some data to the database, call the code, and see what data comes back.

To test what is happening under the hood, you ask the method to tell you what database call it was going to make, and compare it to what you thought it should have made.

Lots of people seem to jump between should you call the database or not in these tests. I think you should. If we don’t hit the database, and instead test what is happening under the hood, then what we test is that specific SQL is generated, but that doesn’t actually test that the SQL does what you think it should do.

There will be reasons why you can’t or shouldn’t call the database during these tests, but they will be specific to your project, I think as a general principal, model scopes should call the database when tested.

With this in mind, I will be showing you how I test Rails model scopes, by setting up some data in the database and testing that only the correct data comes down.

In a real system, I would use factories to set up the models under test, I’ve omitted them to keep the example shorter.

In both examples we;

  • set up three instances of our Book model, two we expect to appear in the scope and one we expect not to
  • call the scope, and ensure the returned data is what we expect

Testing scopes RSpec example

RSpec.describe Book, type: :model do
  describe ".in_irish" do
    it "only includes books that are Irish" do
      cúpla_focal = Book.create!(language: 'irish')
      an_hobad = Book.create!(language: 'irish')
      Book.create!(language: 'english')
      expect(Book.in_irish).to contain_exactly(cúpla_focal, an_hobad)
    end
  end
end

In RSpec I’m using contain_exactly which says “you should only include what I’ve passed in, but in any order”.

Testing scopes Minitest example

class TestBookScopes < Minitest::Test
  def test_in_irish_scope_returns_only_irish_books
    cúpla_focal = Book.create!(language: 'irish')
    an_hobad = Book.create!(language: 'irish')
    Book.create!(language: 'english')
    
    assert_equal [cúpla_focal, an_hobad].sort, Book.in_irish.sort
  end
end

In Minitest I’ve added sort to both sides so that the test won’t fail just because the database brings them back in a different order.

If our scope was involved in changing the order of the returned objects, then we wouldn’t force a change like this.

Keeping scopes simple by chaining them

Because our scope does one thing (checks for language: 'irish'), our testing is straightforward. If the scope was more complex, either by including variable input or by having multiple attributes to compare, then our tests will also need to become more complex.

One way to protect against complex scopes is to chain them.

Scopes return an ActiveRecord::Relation object, which allows you to further call other ActiveRecord methods on it, including other scopes.

By combining scopes, often called chaining, we can ensure that each individual scope is nice and small, but their additive effect is mighty!

scope :in_irish, -> { where(language: 'irish') }
scope :recent, -> { where('publish_year >= 2020') }

This would let us call Book.recent.in_irish.

Our scope tests can remain nice and simple, testing in_irish and recent in isolation.

Any tests we need to write to confirm our business logic for the specific time our system cares about a book being both recent and written in Irish can stub out or handle this however it wishes.


  1. Don’t mistake this for advice to keep everything super short. Fewer characters often doesn’t mean better code. But when we’re writing a sentence (e.g. where blah and blah) which can be replaced by a word, we should. 

Recent posts View all

WritingGit

How to speed up Rubocop

A small bit of config that could speed up your Rubocop runs

Web Dev

Purging DNS entries

I had no idea you can ask some public DNS caches to purge your domain to help speed things along