24

I have the following (simplified) Rails Concern:

module HasTerms
  extend ActiveSupport::Concern

  module ClassMethods
    def optional_agreement
      # Attributes
      #----------------------------------------------------------------------------
      attr_accessible :agrees_to_terms
    end

    def required_agreement
      # Attributes
      #----------------------------------------------------------------------------
      attr_accessible :agrees_to_terms

      # Validations
      #----------------------------------------------------------------------------
      validates :agrees_to_terms, :acceptance => true, :allow_nil => :false, :on => :create
    end
  end
end

I can't figure out a good way to test this module in RSpec however - if I just create a dummy class, I get active record errors when I try to check that the validations are working. Has anyone else faced this problem?

5 Answers 5

47

Check out RSpec shared examples.

This way you can write the following:

# spec/support/has_terms_tests.rb
shared_examples "has terms" do
   # Your tests here
end


# spec/wherever/has_terms_spec.rb
module TestTemps
  class HasTermsDouble
    include ActiveModel::Validations
    include HasTerms
  end
end

describe HasTerms do

  context "when included in a class" do
    subject(:with_terms) { TestTemps::HasTermsDouble.new }

    it_behaves_like "has terms"
  end

end


# spec/model/contract_spec.rb
describe Contract do

  it_behaves_like "has terms"

end
Sign up to request clarification or add additional context in comments.

4 Comments

This is clearly the best answer. One can be explicit in the Dummy case AND test that same API in the parent class's specs. This makes all the difference when dealing with any "flexible" APIs (read: method_missing). There are simply some cases one cannot think of until it's in use in a "real" (non-dummy) class, and the shared examples will do a good job of exercising the code in each necessary context.
This falls apart when your module adds dynamic attributes. Let's say your module allows the class method: allows_upload :csv, which adds methods like csv_file_path and csv_file_size. But you have another model that calls the uploaded file :attachment. Now your "acts as upload" spec is going to fail because one is adding csv_file_path and one has attachment_file_path. For this reason I feel like in a lot of cases it's going to suit your needs best to use a dummy class to test the module's behavior as in @Martijn 's answer
@nzifnab to be clear, the module is not adding the method, the child class is explicitly. Whether shared examples is appropriate here is a judgement call specific to the code base. However, you can still use them in this manner. It is possible to pass info to them, just as you do in the call: it_behaves_like 'acts as upload', :csv
@AaronK Oh! I didn't realize you could pass a value to it. Turns out you can also give it a block to give the shared example some context. Something like: it_behaves_like 'an upload', :csv do; subject{ Upload.new(file: some_dummy_file) }; end Thanks for the tip :P
7

You could just test the module implicitly by leaving your tests in the classes that include this module. Alternatively, you can include other requisite modules in your dummy class. For instance, the validates methods in AR models are provided by ActiveModel::Validations. So, for your tests:

class DummyClass
  include ActiveModel::Validations
  include HasTerms
end

There may be other modules you need to bring in based on dependencies you implicitly rely on in your HasTerms module.

2 Comments

I agree that implicit testing is easy, but I feel like I need to be able to test things explicitly as well. This is especially relevant when it comes to writing class functions that none of my classes are using yet.
Something like the DummyClass is the alternative you're looking for then.
7

I was struggling with this myself and conjured up the following solution, which is much like rossta's idea but uses an anonymous class instead:

it 'validates terms' do
  dummy_class = Class.new do
    include ActiveModel::Validations
    include HasTerms

    attr_accessor :agrees_to_terms

    def self.model_name
      ActiveModel::Name.new(self, nil, "dummy")
    end
  end

  dummy = dummy_class.new
  dummy.should_not be_valid
end

1 Comment

This is the only solution I've found to work. When you go with the named dummy class your specs start bleeding into each other if one calls a method that modifies the class, then the next spec will see that class modification too. Although mine is more like let(:dummy_class){ Class.new (... ) } as opposed to inserting it right into the it block.
4

Here is another example (using Factorygirl's "create" method" and shared_examples_for)

concern spec

#spec/support/concerns/commentable_spec
require 'spec_helper'
shared_examples_for 'commentable' do
  let (:model) { create ( described_class.to_s.underscore ) }
  let (:user) { create (:user) }

  it 'has comments' do
    expect { model.comments }.to_not raise_error
  end
  it 'comment method returns Comment object as association' do
    model.comment(user, "description")
    expect(model.comments.length).to eq(1)
  end
  it 'user can make multiple comments' do
    model.comment(user, "description")
    model.comment(user, "description")
    expect(model.comments.length).to eq(2)
  end
end

commentable concern

module Commentable
  extend ActiveSupport::Concern
  included do
    has_many :comments, as: :commentable
  end

  def comment(user, description)
    Comment.create(commentable_id: self.id,
                  commentable_type: self.class.name,
                  user_id: user.id,
                  description: description
                  )
  end

end

and restraunt_spec may look something like this (I'm not Rspec guru so don't think that my way of writing specs is good - the most important thing is at the beginning):

require 'rails_helper'

RSpec.describe Restraunt, type: :model do
  it_behaves_like 'commentable'

  describe 'with valid data' do
    let (:restraunt) { create(:restraunt) }
    it 'has valid factory' do
      expect(restraunt).to be_valid
    end
    it 'has many comments' do
      expect { restraunt.comments }.to_not raise_error
    end
  end
  describe 'with invalid data' do
    it 'is invalid without a name' do
      restraunt = build(:restraunt, name: nil)
      restraunt.save
      expect(restraunt.errors[:name].length).to eq(1)
    end
    it 'is invalid without description' do
      restraunt = build(:restraunt, description: nil)
      restraunt.save
      expect(restraunt.errors[:description].length).to eq(1)
    end
    it 'is invalid without location' do
      restraunt = build(:restraunt, location: nil)
      restraunt.save
      expect(restraunt.errors[:location].length).to eq(1)
    end
    it 'does not allow duplicated name' do
      restraunt = create(:restraunt, name: 'test_name')
      restraunt2 = build(:restraunt, name: 'test_name')
      restraunt2.save
      expect(restraunt2.errors[:name].length).to eq(1)
    end
  end
end

Comments

3

Building on Aaron K's excellent answer here, there are some nice tricks you can use with described_class that RSpec provides to make your methods ubiquitous and make factories work for you. Here's a snippet of a shared example I recently made for an application:

shared_examples 'token authenticatable' do
  describe '.find_by_authentication_token' do
    context 'valid token' do
      it 'finds correct user' do
        class_symbol = described_class.name.underscore
        item = create(class_symbol, :authentication_token)
        create(class_symbol, :authentication_token)

        item_found = described_class.find_by_authentication_token(
          item.authentication_token
        )

        expect(item_found).to eq item
      end
    end

    context 'nil token' do
      it 'returns nil' do
        class_symbol = described_class.name.underscore
        create(class_symbol)

        item_found = described_class.find_by_authentication_token(nil)

        expect(item_found).to be_nil
      end
    end
  end
end

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.