Search code examples
ruby-on-railsunit-testingtestingrspecintegration-testing

How to test composite Rails model scopes?


Consider the following model:

class Model < ActiveRecord::Base
  scope :a,       -> () { where(a: 1) }
  scope :b,       -> () { where(b: 1) }
  scope :a_or_b,  -> () { a.or(b) }
end

now to properly test each scope, I would at least provide an exaple that matches and one that doesn''t for each possible variable. Something like this:

RSpec.describe Model do
  describe "scopes" do
    describe ".a" do
      subject { Model.a }
      let!(:with_a_0) { create(:model, a: 0) }
      let!(:with_a_1) { create(:model, a: 1) }

      it { should_not include(with_a_0) }
      it { should     include(with_a_1) }
    end

    describe ".b" do
      subject { Model.b }
      let!(:with_b_0) { create(:model, b: 0) }
      let!(:with_b_1) { create(:model, b: 1) }

      it { should_not include(with_b_0) }
      it { should     include(with_b_1) }
    end

    describe ".a_or_b" do
      subject { Model.a_or_b }
      let!(:with_a_0_and_b_0) { create(:model, a: 0, b: 0) }
      let!(:with_a_0_and_b_1) { create(:model, a: 0, b: 1) }
      let!(:with_a_1_and_b_0) { create(:model, a: 1, b: 0) }
      let!(:with_a_1_and_b_1) { create(:model, a: 1, b: 1) }

      it { should_not include(with_a_0_and_b_0) }
      it { should     include(with_a_0_and_b_1) }
      it { should     include(with_a_1_and_b_0) }
      it { should     include(with_a_1_and_b_1) }
    end
  end
end

But then it feels like I'm retesting .a and .b on the .a_or_b test, and if I compose it again, with yet another scope, it'll get bigger and bigger.

What is the sane way of dealing with this?

Also: is this a unit or integration test?


Solution

  • That's a tough one. I'd say you have to find an Aristotelian mean between full coverage, and your specs readability. It's probably not reasonable to test every combination of possible states that influence how your scopes are behaving.

    Also it's not really a unit test, because it's coupled with the DB layer.

    My approach is this:

    Don't test simple scopes (like a and b in your example), trust that AR is well tested, make yous scope names clear and leave it.

    In more complex scopes, you can invert the way you compose tests: Create specimens once, then define expectations for different scopes (and make the specimens names very clear, like you're doing already).

    This will reduce the size of the spec a bit, and it's easier to read such specs. But it still will grow the spec size if you want to have full coverage for every scope.

    RSpec.describe Model do
      describe "scopes" do
        let!(:with_a_0_and_b_0) { create(:model, a: 0, b: 0) }
        let!(:with_a_0_and_b_1) { create(:model, a: 0, b: 1) }
        let!(:with_a_1_and_b_0) { create(:model, a: 1, b: 0) }
        let!(:with_a_1_and_b_1) { create(:model, a: 1, b: 1) }
    
        it { expect(described_class.a).to include(with_a_1_and_b_1).and include(with_a_1_and_b_1) }
        it { expect(described_class.a).not_to include(with_a_0_and_b_0) }
    
        # you get the idea
    

    To make this^ even more readable, I'd suggest creating a custom matcher, you could use like this:

      it do 
        expect(described_class.a)
          .to retrieve(with_a_1_and_b_0, with_a_1_and_b_1)
          .and not_retrieve(with_a_0_and_b_0, with_a_0_and_b_1)
      end