Search code examples
ruby-on-railsruby-on-rails-4rspecrspec-rails

Use of Spies for detecting method calls


I'm new to RSpec. I'm using the following (correct) piece of spec code for making sure that the methods find and same_director are called in the tested controller:

require 'rails_helper'

describe MoviesController do
    describe 'Searching movies for same director: ' do
        before :each do
            @movie1 = instance_double('Movie', id:1, title:'movie1')
            @movie2 = instance_double('Movie', id:2, title:'movie2')
            @any_possible_id = 1
        end
        it 'should call the model method that finds the movie corresponding to the passed id.' do
            allow(@movie1).to receive(:same_directors).and_return [@movie1,@movie2]
            expect(Movie).to receive(:find).with(@any_possible_id.to_s).and_return @movie1
            get :directors, :id=>@any_possible_id
        end
        it 'should call the model method that finds the movies corresponding to the same director.' do
            expect(@movie1).to receive(:same_directors).and_return [@movie1,@movie2]
            allow(Movie).to receive(:find).with(@any_possible_id.to_s).and_return @movie1
            get :directors, :id=>@any_possible_id
        end

This is working fine, but as you can see, the two it-clauses contain a repeated get :directors, :id=>@any_possible_id line. Given the order of the statement in the controller (see the controller code at the end of the question), the get has to appear at the end of the it-clause. The challenge is to DRY it out by moving it to the before-clause. Of course, the best solution is to just move it to an after-clause (I realized that while I was writing the question, I did it and it works.) But what I want now is to understand why my use of Spies here is not working.

My (failing) attempt with Spies is:

describe MoviesController do
    describe 'Searching movies for same director: ' do
        before :each do
            @movie1 = instance_double('Movie', id:1, title:'movie1')
            @movie2 = instance_double('Movie', id:2, title:'movie2')
            @any_possible_id = 1
            @spyMovie = class_spy(Movie)
            @spymovie1 = instance_spy(Movie)
            get :directors, :id=>@any_possible_id
        end
        it 'should call the model method that finds the movie corresponding to the passed id.' do
            allow(@spymovie1).to receive(:same_directors)#.and_return [@movie1,@movie2]
            expect(@spyMovie).to have_received(:find).with(@any_possible_id.to_s)#.and_return @movie1
        end
        it 'should call the model method that finds the movies corresponding to the same director.' do
            expect(@spymovie1).to have_received(:same_directors)#.and_return [@movie1,@movie2]
            allow(@spyMovie).to receive(:find).with(@any_possible_id.to_s)#.and_return @movie1
        end

Now, there might be several problems with this second piece of code, but the error I am getting when running rspec is:

  1) MoviesController Searching movies for same director:  should call the model method that finds the movie corresponding to the passed id.
     Failure/Error: expect(@spyMovie).to have_received(:find).with(@any_possible_id.to_s)#.and_return @movie1
       (ClassDouble(Movie) (anonymous)).find("1")
           expected: 1 time with arguments: ("1")
           received: 0 times
     # ./spec/controllers/movies_controller_spec.rb:32:in `block (3 levels) in <top (required)>'

  2) MoviesController Searching movies for same director:  should call the model method that finds the movies corresponding to the same director.
     Failure/Error: expect(@spymovie1).to have_received(:same_directors)#.and_return [@movie1,@movie2]
       (InstanceDouble(Movie) (anonymous)).same_directors(*(any args))
           expected: 1 time with any arguments
           received: 0 times with any arguments
     # ./spec/controllers/movies_controller_spec.rb:35:in `block (3 levels) in <top (required)>'

So, you can see that the calls to :find and to :same_directors are not being detected when I use the spies.

I couldn't get the answer in rspec.info nor in relishapp. I would very much appreciate it if you can suggest something. Thanks!

In case you need it, this is the controller:

  def directors
    @movie = Movie.find(params[:id])
    @movies = @movie.same_directors
    if (@movies-[@movie]).empty?
      flash[:notice] = "'#{@movie.title}' has no director info"
      redirect_to movies_path
    end
  end

Solution

  • This is a relevant question: besides testing its behavior, one might want to make sure that certain controller methods are called. For example, if those methods are from legacy code, you want to make sure via a self-checking test that someone else is not going to come and substitute her own methods in the controller. (Thank you anyway max, I appreciate your time).

    This is the code that answers the question:

    before :each do
        @movie1 = spy('Movie')
        allow(Movie).to receive(:find).and_return @movie1
        @any_possible_id = 1
        get :directors, :id=>@any_possible_id
    end
    it 'should call the model method that finds the movie corresponding to the passed id.' do
        expect(Movie).to have_received(:find).with(@any_possible_id.to_s)
    end
    it 'should call the model method that finds the movies corresponding to the same director.' do
        expect(@movie1).to have_received(:same_directors)
    end
    

    Explanation

    Line 2 - @movie1 is defined as a spy out of the class Movie. Being a spy, we can use it later in expect(@movie1).to have_received(:anything). If @movie1 is defined as a double then it becomes necessary to use an :allow clause to allow it to receive the method anything (or "message", as the parlance of the docs).

    Line 3 - This line is the key for explaining the error thrown by RSpec. The .and_return @movie1 is fundamental for telling RSpec that @movie1 is playing the role of @movie in the controller (see the controller code at the end of the question). RSpec establishes this match from seeing that Movie.find in the controller returns @movie, as specified in this allow statement. The reason for the error in the question is not that Movie or @movie were not receiving the specified methods in the controller; the reason is that RSpec was not matching Movie and @movie1 in the spec with the real Movie and @movie in the controller.

    The rest is self-explanatory. By the way, I came with other ways of writing this test, but this one with the use of the spy is the most compact one.