Rspec advice for testing service objects

17,354

This is the 'Mockist vs Classicist' dilemma addressed in Martin Fowler's Mocks Aren't Stubs. Using mocks (doubles) throughout is necessarily going to require stubbing out other methods on collaborators and exposing the implementation. That is part of the price you pay for the speed and flexibility of mocking.

Another issue is that there is no natural 'subject' for the spec, because this is a class method. You end up with three objects that each need to be updated; in a sense they are alternately subjects and collaborators depending on which expectation is being exercised. You can make this more clear by setting one expectation per example:

describe MealServicer do
  context ".serve_meal" do
    let(:order) { double(:order) }
    let(:meal) { double(:meal) }
    let(:customer) { double(:customer, id: 123, order: order }

    it "updates the meal" do
      allow(OrderServicer).to_receive(:add_meal_to_order)
      allow(CRM).to_receive(:update_customer_record)
      expect(meal).to receive(:update_attributes).with(status: "served", customer_id: 123)
      MealServicer.serve_meal(meal, customer)
    end

    it "adds the meal to the order" do
      allow(meal).to receive(:update_attributes)
      allow(CRM).to_receive(:update_customer_record)
      expect(OrderServicer).to receive(:add_meal_to_order).with(meal, order)
      MealServicer.serve_meal(meal, customer)
    end

    it "updates the customer record" do
      allow(meal).to receive(:update_attributes)
      allow(OrderServicer).to_receive(:add_meal_to_order)
      expect(CRM).to receive(:update_customer_record).with(customer)
      MealServicer.serve_meal(meal, customer)
    end
  end
end

Now the stubs are always the dependencies, and the expectations are the things being tested, which clarifies the intent of the spec.

because the tests depend on the implementation, I cannot write the tests before the method

I disagree. If you separate the expectations, then you can test first and write code to make the tests pass, if you work on one example at a time.

EDIT

see also this blog post by Myron Marston

Share:
17,354
oregontrail256
Author by

oregontrail256

Updated on July 03, 2022

Comments

  • oregontrail256
    oregontrail256 almost 2 years

    I'm writing Rspec tests for a service object that touches several models, but I feel like my test is too dependent on the internals of the method and therefore isn't very meaningful. Here's an example:

    class MealServicer
    
      def self.serve_meal(meal, customer)
        meal.update_attributes(status: "served", customer_id: customer.id)
        order = customer.order
        OrderServicer.add_meal_to_order(meal, order)
        CRM.update_customer_record(customer) // external API call
      end
    
    end
    

    I'd like to use doubles/stubs to mock the behavior without actually saving anything to the test database (for performance). But if I create doubles that respond to messages, then it feels like I'm testing one particular implementation of the serve_meal() method, and this test is too coupled to that particular implementation. For example, I need to make sure that my customer double responds to order and returns an order stub. Essentially, when everything is just a double and I have to explicitly state all dependencies by making sure doubles return other doubles, it feels like the tests end up being pretty meaningless. See here:

    it "has a working serve_meal method" do
      meal = double(:meal)
      customer = double(:customer)
      order = double(:order)
    
      allow(customer).to_receive(:order).and_return(order)
      allow(OrderServicer).to_receive(:add_meal_to_order).and_return(true)
      allow(CRM).to_receive(:update_customer_record).and_return(true)
    
      expect(meal).to receive(:update_attributes).once
      expect(OrderServicer).to receive(:add_meal_to_order).once
      expect(CRM).to receive(:update_customer_record).once
    end
    

    Is there another way to test this thoroughly and meaningfully, other than instantiating actual meal, customer, and order objects connected appropriately (and possibly saved to the datbase), and then check that MealServicer.serve_meal(...) updates the object properties as expected? This will end up saving to the database eventually, because update_attributes does a save call and so do several of the methods I intend to include in my Service object method.

    Lastly because the tests depend on the implementation, I cannot write the tests before the method, which is what TDD advocates recommend. This just feels backwards. Any advice on writing performant but useful tests?