Akira  Noguchi
Akira Noguchi

Reputation: 901

How to stub has_many association in RSpec

I'm try to stub has_many association in RSpec because building records is so complicated. I want to detect which author has science book by using Author#has_science_tag?.

Model

class Book < ApplicationRecord
  has_and_belongs_to_many :tags
  belongs_to :author
end

class Tag < ApplicationRecord
  has_and_belongs_to_many :books
end

class Author < ApplicationRecord
  has_many :books

  def has_science_tag?
    tags = books.joins(:tags).pluck('tags.name')
    tags.grep(/science/i).present?
  end
end

RSpec

require 'rails_helper'

RSpec.describe Author, type: :model do
  describe '#has_science_tag?' do
    let(:author) { create(:author) }

    context 'one science book' do
      example 'it returns true' do
        allow(author).to receive_message_chain(:books, :joins, :pluck).with(no_args).with(:tags).with('tags.name').and_return(['Science'])
        expect(author.has_science_tag?).to be_truthy
      end
    end
  end
end

In this case, using receive_message_chain is good choice? Or stubbing has_many association is bad idea?

Upvotes: 1

Views: 1634

Answers (1)

Martin
Martin

Reputation: 4222

Why dont you make use of FactoryBot associations?

FactoryBot.define do
  # tag factory with a `belongs_to` association for the book
  factory :tag do
    name { 'test_tag' }
    book

    trait :science do
      name { 'science' }  
    end
  end

  # book factory with a `belongs_to` association for the author
  factory :book do
    title { "Through the Looking Glass" }
    author

    factory :science_book do
      title { "Some science stuff" }

      after(:create) do |book, evaluator|
        create(:tag, :science, book: book)
      end
    end
  end

  # author factory without associated books
  factory :author do
    name { "John Doe" }

    # author_with_science_books will create book data after the author has
    # been created
    factory :author_with_science_books do
      # books_count is declared as an ignored attribute and available in
      # attributes on the factory, as well as the callback via the evaluator
      transient do
        books_count { 5 }
      end

      # the after(:create) yields two values; the author instance itself and
      # the evaluator, which stores all values from the factory, including
      # ignored attributes; `create_list`'s second argument is the number of
      # records to create and we make sure the author is associated properly
      # to the book
      after(:create) do |author, evaluator|
        create_list(:science_book, evaluator.books_count, authors: [author])
      end
    end
  end
end

This allows you to do:

create(:author).books.count # 0
create(:author_with_science_books).books.count # 5
create(:author_with_science_books, books_count: 15).books.count # 15

So your test becomes:

RSpec.describe Author, type: :model do
  describe '#has_science_tag?' do
    let(:author_with_science_books) { create(:author_with_science_books, books_count: 1) }

    context 'one science book' do
      it 'returns true' do
        expect(author_with_science_books.has_science_tag?).to eq true
      end
    end
  end
end

And you could also refactor Author#has_science_tag?:

class Author < ApplicationRecord
  has_many :books

  def has_science_tag?
    books.joins(:tags).where("tags.name ILIKE '%science%'").exists?
  end
end

Upvotes: 1

Related Questions