Search code examples
ruby-on-railsrspecsimple-form

RSpec + form object + simple form gives undefined methods


I am trying to do some mocking in specs for controller. I have written the code that works just fine for case when I post valid article, but when I try to specify it shouldn't be saved, I am facing errors.

My code:

Article form:

class ArticleForm
  include ActiveModel::Model
  delegate :title, :body, :author_id, :tags, :id, :persisted?, :new_record?, to: :article
  attr_accessor :article, :tags_string
  validates :title, :body, :tags, presence: true
  validates_length_of :title, within: 8..512
  validates_length_of :body, within: 8..2048
  validate :validate_prohibited_words

  def initialize(article = Article.new)
    @article = article
  end

  def save(article_params)
    assign_params_to_article(article_params)

    if valid?
      @article.tags.each(&:save!)
      @article.save!
      true
    else
      false
    end
  end
...
end

Articles controller (only create action):

  def create
    @article_form = ArticleForm.new
    if @article_form.save(article_params)
      flash[:notice] = 'You have added a new article.'
      redirect_to @article_form.article
    else
      flash[:danger] = 'Failed to add new article.'
      render :new
    end
  end

_form:

= simple_form_for @article_form,
  url: (@article_form.article.new_record? ? articles_path : article_path(@article_form.article) ) do |f|
  = f.input :title, label: "Article title:"
  = f.input :body, label: "Body of the article:", as: :text, input_html: { :style => 'height: 200px' }
  = f.input :tags_string, label: "Tags:", input_html: { value: f.object.all_tags }
  = f.button :submit, 'Send!'

Article controller spec:

require 'rails_helper'

RSpec.describe ArticlesController, type: :controller do
  render_views

  let!(:user) { create(:user) }
  let!(:tag) { create(:tag) }
  let(:tags_string) { 'test tag' }
  let!(:article) { create(:article, :with_comments, tags: [tag], author_id: user.id) }


  context 'user logged in' do
    before { sign_in(user) }

    describe 'POST artictles#create' do
      let(:article_form) { instance_double(ArticleForm) }
      let(:form_params) do
        {
          article_form:
          {
            title: 'title',
            body: 'body',
            tags_string: tags_string
          }
        }
      end

      context 'user adds valid article' do
        it 'redirects to new article', :aggregate_failures do
          expect(ArticleForm).to receive(:new).and_return(article_form)
          expect(article_form).to receive(:save).with(hash_including(:author_id, form_params[:article_form]))
                                                .and_return(true)
          allow(article_form).to receive(:article) { article }

          post :create, params: form_params
          expect(response).to redirect_to(article)
        end
      end

      context 'user adds invalid article' do
        it 'renders new form', :aggregate_failures do
          expect(ArticleForm).to receive(:new).and_return(article_form)
          allow(article_form).to receive(:article) { article }

          expect(article_form).to receive(:save).with(hash_including(:author_id, form_params[:article_form]))
                                                .and_return(false)

          post :create, params: form_params
          expect(response).to render_template(:new)
        end
      end
    end
  end
end

Posting valid works fine, this is the error I get on 'invalid post':

Failures:

1) ArticlesController user logged in POST artictles#create user adds invalid article renders new form Got 1 failure and 1 other error:

 1.1) Failure/Error: = simple_form_for @article_form,
        #<InstanceDouble(ArticleForm) (anonymous)> received unexpected message :model_name with (no args)
      # ./app/views/articles/_form.html.haml:2:in `_app_views_articles__form_html_haml__2443549359101958040_47338976410880'
      # ./app/views/articles/new.html.haml:2:in `_app_views_articles_new_html_haml___678894721646621807_47338976253400'
      # ./app/controllers/articles_controller.rb:26:in `create'
      # ./spec/controllers/articles_controller_spec.rb:51:in `block (5 levels) in <top (required)>'

 1.2) Failure/Error: = simple_form_for @article_form,

      ActionView::Template::Error:
        undefined method `param_key' for #<Array:0x0000561bedbcd888>
      # ./app/views/articles/_form.html.haml:2:in `_app_views_articles__form_html_haml__2443549359101958040_47338976410880'
      # ./app/views/articles/new.html.haml:2:in `_app_views_articles_new_html_haml___678894721646621807_47338976253400'
      # ./app/controllers/articles_controller.rb:26:in `create'
      # ./spec/controllers/articles_controller_spec.rb:51:in `block (5 levels) in <top (required)>'
      # ------------------
      # --- Caused by: ---
      # NoMethodError:
      #   undefined method `param_key' for #<Array:0x0000561bedbcd888>
      #   ./app/views/articles/_form.html.haml:2:in `_app_views_articles__form_html_haml__2443549359101958040_47338976410880'

I have made few tries to add missing methods by allowing the object to receive it, but is it a good thing to do? I would have to allow every single call later on (when I allow param_keys, it then asks for all the values for the _form - title, body and tags). Is there a way to get it working without specifying all the methods line by line?


Solution

  • Controller tests in Rails are used for "functional" testing. This means they test several layers of the of request that is being sent to application.

    So basically you are trying to test that all the pieces that are involved in processing the request work. Because of this using mocks and stubs is not desired in these kinds of tests, because you are trying to make the test as "real" as possible. I would recommend instantiating a real ArticleForm object instead of creating an instance double.

    let(:article_form) { ArticleForm.new(article) } 
    

    This way you are also testing the ArticleForm instance as well. There are cases where stubbing or mocking can make sense in a controller test as well, but in your case the article_form instance is at the heart of the test, so using a mock for it, means awful lot of work and the test will get more complicated.

    If you want to try out mocking, a better starting point could be view specs for example.