How do I write short, clean rspec tests for a method with many model calls?

I am having problems with some tests for a method I want to write.

This method will take a hash of some data and create a bunch of related models with it. The problem is that I am having a hard time determining what is best for writing such a test.

For example, the code would be:

Take a hash that looks like this:

{
  :department => 'CS',
  :course_title => 'Algorithms',
  :section_number => '01B'
  :term => 'Fall 2012',
  :instructor => 'Bob Dylan'
}

      

And save it in the models Department

, Course

, Section

and Instructor

.

It will take a lot of calls model.find_or_create

, etc.

How can I go about testing each individual target of this method, for example:

it 'should find or create department' do
  # << Way too many stubs here for each model and all association calls
  dept = mock_model(Department)
  Department.should_receive(:find_or_create).with(:name => 'CS').and_return(dept)
end

      

Is there a way to avoid the massive amount of stubs so that every test is FIRST (fast independent repeatable self-test in a timely manner)? Is there a better way to write this method and / or these tests? I would rather have short, clean blocks it

.

Thank you so much for any help.

Edit: This method will probably look like this:

def handle_course_submission(param_hash)
  department = Department.find_or_create(:name => param_hash[:department])
  course = Course.find_or_create(:title => param_hash[:course_title])
  instructor = Instructor.find_or_create(:name => param_hash[:instructor])
  section = Section.find_or_create(:number => param_hash[:section_number], :term => param_hash[:term])

  # Maybe put this stuff in a different method?
  course.department = department
  section.course = course
  section.instructor = instructor

end

      

Is there a better way to write a method? How should I write tests? Thank!

+3


source to share


3 answers


To pass an array of sections to create:

class SectionCreator

  # sections is the array of parameters
  def initialize(sections)
    @sections = sections
  end

  # Adding the ! here because I think you should use the save! methods
  # with exceptions as mentioned in one of my myriad comments.
  def create_sections!
    @sections.each do |section|
      create_section!(section)
    end
  end

  def create_section!(section)
    section = find_or_create_section(section[:section_number], section[:term])
    section.add_course!(section_params)
  end

  # The rest of my original example goes here

end

# In your controller or wherever...

def action
  SectionCreator.new(params_array).create_sections!
rescue ActiveRecord::RecordInvalid => ex
  errors = ex.record.errors
  render json: errors
end

      



Hope this covers it all.

+1


source


My first thought is that you may be suffering from a larger design flaw. Without seeing more context for your method, it's hard to give a lot of advice. However, in general, it is useful to break the method down into smaller chunks and follow the principle of a single level of abstraction.

http://www.markhneedham.com/blog/2009/06/12/coding-single-level-of-abstraction-principle/

Here's what you could try, although as mentioned earlier, it's definitely still not perfect:

def handle_course_submission(param_hash)
  department = find_or_create_department(param_hash[:department])
  course = find_or_create_course(param_hash[:course_title])
  # etc.
  # call another method here to perform the actual work
end

private

def find_or_create_department(department)
  Department.find_or_create(name: department)
end

def find_or_create_course(course_title)
  Course.find_or_create(title: course_title)
end

# Etc. 

      



In the specification ...

let(:param_hash) do
  {
    :department => 'CS',
    :course_title => 'Algorithms',
    :section_number => '01B'
    :term => 'Fall 2012',
    :instructor => 'Bob Dylan'
  }
end

describe "#save_hash" do
  before do
    subject.stub(:find_or_create_department).as_null_object
    subject.stub(:find_or_create_course).as_null_object
    # etc.
  end

  after do
    subject.handle_course_submission(param_hash)
  end

  it "should save the department" do
    subject.should_receive(:find_or_create_department).with(param_hash[:department])
  end

  it "should save the course title" do
    subject.should_receive(:find_or_create_course).with(param_hash[:course_title])
  end

  # Etc.

end

describe "#find_or_create_department" do
  it "should find or create a Department" do
    Department.should_receive(:find_or_create).with("Department Name")
    subject.find_or_create_department("Department Name")
  end
end

# etc. for the rest of the find_or_create methods as well as any other
# methods you add

      

Hope some of them helped a little. If you post more of your example code, I can provide less generalized and possibly helpful advice.

+1


source


Given the new context, I would like to split the functionality a bit among your models. Again, this is really just the first thing that comes to mind and can certainly be improved. It seems to me that Section

is the root object. So you can either add a method Section.create_course

or wrap it in a service object like this:

Updated this example to use exceptions

class SectionCreator

  def initialize(param_hash)
    number = param_hash.delete(:section_number)
    term = param_hash.delete(:term)

    @section = find_or_create_section(number, term)
    @param_hash = param_hash
  end

  def create!
    @section.add_course!(@param_hash)
  end

  private

  def find_or_create_section(number, term)
    Section.find_or_create(number: number, term: term)
  end

end

class Section < ActiveRecord::Base

  # All of your current model stuff here

  def add_course!(course_info)
    department_name = course_info[:department]
    course_title = course_info[:course_title]
    instructor_name = param_hash[:instructor]

    self.course = find_or_create_course_with_department(course_title, department_name)
    self.instructor = find_or_create_instructor(instructor_name)
    save!

    self
  end

  def find_or_create_course_with_department(course_title, department_name)
    course = find_or_create_course(course_title)
    course.department = find_or_create_department(department_name)
    course.save!
    course
  end

  def find_or_create_course(course_title)
    Course.find_or_create(title: course_title)
  end

  def find_or_create_department(department_name)
    Department.find_or_create(name: department_name)
  end

  def find_or_create_instructor(instructor_name)
    Instructor.find_or_create(name: instructor_name)
  end

end

# In your controller (this needs more work but..)
def create_section_action
  @section = SectionCreator.new(params).create!
rescue ActiveRecord::RecordInvalid => ex
  flash[:alert] = @section.errors
end

      

Note that adding a method #find_or_create_course_with_department

allowed us to add a department association there, leaving the method #add_course

pure. This is why I like to add these methods, even if they sometimes seem trivial, as in the case of a method #find_or_create_instructor

.

Another advantage of developing methods this way is that they become easier to stub out in tests, as I showed in my first example. You can easily stub all of these methods to ensure that the database hasn't actually been hit and your tests run quickly and at the same time ensure that the test expectations match functionality.

Of course, a lot of it comes down to personal preference as to how you want to implement it. In this case, the service object is probably not needed. You could just as easily implement this as the method Section.create_course

I referred to earlier:

class Section < ActiveRecord::Base

  def self.create_course(param_hash)
    section = find_or_create(number: param_hash.delete(:section_number), term: param_hash.delete(:term))
    section.add_course(param_hash)
    section
  end

  # The rest of the model goes here
end

      

As for your final question, you can definitely stub the methods in RSpec and then apply the same expectations to them as should_receive

.

It's getting late for me, so let me know if I missed something.

+1


source







All Articles