Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rspec advice for testing service objects

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?

like image 632
oregontrail256 Avatar asked May 17 '14 07:05

oregontrail256


1 Answers

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

like image 194
zetetic Avatar answered Oct 23 '22 09:10

zetetic