30

Sep

RSpec Tutorial Part 4 - Mocking

Mocking

Consider the following controller action:

  def comment
    @post = Post.find(params[:id])
    @comment = Comment.new(params[:comment])
    respond_to do |format|
      if @comment.save && @post.comments.concat(@comment)
        flash[:notice] = 'Comment was successfully updated.'
        format.html { redirect_to post_path(@blogger, @post) }
        format.xml  { head :ok }
      else
        flash[:notice] = 'Error saving comment.'
        format.html { redirect_to post_path(@blogger, @post) }
        format.xml  { render :xml => @comment.errors.to_xml }
      end
    end
  end

I need to test that I'm able to comment on posts, so I could do this:

  it "should add comments to the post" do
    post = posts(:one)
    comment_count = post.comments.size
    post :comment, :id => 1, :comment => {:title => 'test', :body => 'very nice!'}
    post.reload
    post.comments.size.should == comment_count + 1
    flash[:notice].should match(/success/i) 
    response.should redirect_to(posts_path(@blogger, @post))
  end

The problem with this test is that I'm testing much more than I need to. I'm testing functionality that I've already tested in the models. Even worse, I'm dependent upon the upkeep of that fixture file.

I'll use a fake post instead:

  post = mock_model(Post)

The next thing I need to worry about is the first line in the comment action:

  @post = Post.find(params[:id])

In testing just the functionality of the controller, I want to make sure this line is executed, but I can assume the method itself works. The Rails team has already tested that much better than I can. I need to intercept that call and return my mocked model. There are two different ways to do this:

  Post.should_receive(:find).and_return(post)

Or:

  Post.stub!(:find).and_return(post)

The difference between stubbing the find method and mocking it is huge here. If I use '.stub!', then Post will return my mock post if the .find method is called. If it is not called my stub won't care at all. If I mock it with 'should_receive' then it will care: It will raise an error. Think of 'should_receive' as a whiny stub.

The 'and_return(post)' does exactly the same thing in each instance: it returns whatever you pass to it when your mocked or stubbed method is called.

Next, I want to make sure that '.new' is called on Comment with the attributes I'm sending via params.

  comment = mock_model(Comment)
  Comment.should_receive(:new).with({:title => 'comment title', :body => 'comment body'}).and_return(comment)

Now I'm getting to the good part. I have to satisfy the necessary conditions for the following expressions in my action:

  if @comment.save && @post.comments.concat(@comment)

Now I'm going to build out my comment mock:

  comment.should_receive(:save).and_return(true)
  post.comments.should_receive(:concat).and_return(comment)

Excellent, now I've mocked enough to get to where I can test my flash and the redirect:

  flash[:notice].should match(/success/i)
  response.should redirect_to(posts_path(blogger, post))

There's one last thing, the action has to have a blogger to redirect properly. I'll use instance_variable_set on the controller to do that.

  blogger = mock_model(User)
  @controller.instance_variable_set(:@blogger, blogger)

Here's what the entire spec looks like. I've separated out the preparatory work into a before section of the spec:

describe PostsController, "comment", :behaviour_type => :controller, do

  before do
    @post = mock_model(Post)
    Post.should_receive(:find).and_return(@post)
    @comment = mock_model(Comment)
    Comment.should_receive(:new).with.({:title => 'comment title', :body => 'comment body'}).and_return(@comment)
    @blogger = mock_model(User)
    @controller.instance_variable_set(:@blogger, @blogger)
  end

  it "should add comments to the post" do
    @comment.should_receive(:save).and_return(true)
    @post.comments.should_receive(:concat).and_return(@comment)
    post :comment, :id => @post.id, :comment => {:title => 'comment title', :body => 'comment body'}
    flash[:notice].should match(/success/i) 
    response.should redirect_to(posts_path(@blogger, @post))
  end

end