Fast (Rspec) tests with and without Rails

1k views Asked by At

I have two classes:

1.Sale is a subclass of ActiveRecord; its job is to persist sales data to the database.

class Sale < ActiveRecord::Base
  def self.total_for_duration(start_date, end_date)
    self.count(conditions: {date: start_date..end_date})
  end
  #...
end

2.SalesReport is a standard Ruby class; its job is to produce and graph information about Sales.

class SalesReport
  def initialize(start_date, end_date)
    @start_date = start_date
    @end_date = end_date
  end

  def sales_in_duration
    Sale.total_for_duration(@start_date, @end_date)
  end
  #...
end

Because I want to use TDD and I want my tests to run really fast, I have written a spec for SalesReport that doesn't doesn't load Rails:

require_relative "../../app/models/sales_report.rb"

class Sale; end
# NOTE I have had to re-define Sale because I don't want to
# require `sale.rb` because it would then require ActiveRecord.

describe SalesReport do
  describe "sales_in_duration" do
    it "calls Sale.total_for_duration" do
      Sale.should_receive(:total_for_duration)
      SalesReport.new.sales_in_duration
    end
  end
end

This test works when I run bundle exec rspec spec/models/report_spec.rb.

However this test fails when I run bundle exec rake spec with the error superclass mismatch for class Sale (TypeError). I know the error is happening because Tap is defined by sale.rb and inline within the spec.

So my question is there a way to Stub (or Mock or Double) a class if that class isn't defined? This would allow me to remove the inline class Sale; end, which feels like a hack.

If not, how do I set up my tests such that they run correctly whether I run bundle exec rspec or bundle exec rake spec?

If not, is my approach to writing fast tests wrong?!

Finally, I don't want to use Spork. Thanks!

2

There are 2 answers

0
Myron Marston On BEST ANSWER

RSpec's recently added stub_const is specifically designed for cases like these:

describe SalesReport do
  before { stub_const("Sale", Class.new) }

  describe "sales_in_duration" do
    it "calls Sale.total_for_duration" do
      Sale.should_receive(:total_for_duration)
      SalesReport.new.sales_in_duration
    end
  end
end

You may also want to use rspec-fire to use a test double in place of Sale that automatically checks all the mocked/stubbed methods exist on the real Sale class when running your tests with the real Sale class loaded (e.g. when you run your test suite):

require 'rspec/fire'

describe SalesReport do
  include RSpec::Fire

  describe "sales_in_duration" do
    it "calls Sale.total_for_duration" do
      fire_replaced_class_double("Sale")
      Sale.should_receive(:total_for_duration)
      SalesReport.new.sales_in_duration
    end
  end
end

If you rename total_for_duration on the real Sale class, rspec-fire will give you an error when you mock the method since it doesn't exist on the real class.

0
dmcnally On

A simple way would be to check if "Sale" has already been defined

unless defined?(Sale)
  class Sale; end
end

Sale need not be a class either in your test so:

unless defined?(Sale)
  Sale = double('Sale')
end