Using Partial Doubles Effectively

Partial doubles are really easy to use: just stub or expect a message on any object! However, we said in Partial Doubles that we consider their usage to be a code smell. We’d like to flesh that statement out a bit now.

Most unit tests involve a mixture of two types of objects:

Partial doubles don’t fit neatly in this hierarchy. They are partially real and partially fake. Are they part of what you are testing or part of the environment you are constructing? When an object’s roles are unclear, your tests can be harder to reason about.

We prefer not to mix these roles in the same object. In some cases, it can have some serious consequences. Let’s look at an example.

Many Software as a Service (SaaS) apps use a monthly subscription model, where customers are charged every month. Here’s a typical implementation, using a hypothetical billing API called CashCow:

15-using-test-doubles-effectively/06/subscription_service/lib/recurring_payment.rb
 class​ RecurringPayment
 def​ self.process_subscriptions(subscriptions)
  subscriptions.each ​do​ |subscription|
  CashCow.charge_card(subscription.credit_card, subscription.amount)
 # ...send receipt and other stuff...
 end
 end
 end

The unit spec for this class verifies that we’re charging the correct amounts:

15-using-test-doubles-effectively/07/subscription_service/spec/recurring_payment_spec.rb
 RSpec.describe RecurringPayment ​do
 it​ ​'charges the credit card for each subscription'​ ​do
  card_1 = Card.new(​:visa​, ​'1234 5678 9012 3456'​)
  card_2 = Card.new(​:mastercard​, ​'9876 5432 1098 7654'​)
 
  subscriptions = [
  Subscription.new(​'John Doe'​, card_1, 19.99),
  Subscription.new(​'Jane Doe'​, card_2, 29.99)
  ]
 
 expect​(CashCow).to receive(​:charge_card​).with(card_1, 19.99)
 expect​(CashCow).to receive(​:charge_card​).with(card_2, 29.99)
 
  RecurringPayment.process_subscriptions(subscriptions)
 end
 end

Here, we are using CashCow as a mock object. We expect it to receive the :charge_card message for each subscription. Crucially, this message expectation also serves to stub the method, preventing a real charge from going through in our tests.

Hundreds of customers later, we may find that our RecurringPayment class is spending a lot of time making a separate API call for each subscription. In this case, we can switch to CashCow’s bulk interface, which lets us make a single API call to charge all of our customers’ cards.

Here’s an updated RecurringPayment class that uses the bulk API call:

15-using-test-doubles-effectively/08/subscription_service/lib/recurring_payment.rb
 class​ RecurringPayment
 def​ self.process_subscriptions(subscriptions)
  cards_and_amounts = subscriptions.each_with_object({}) ​do​ |sub, data|
  data[sub.credit_card] = sub.amount
 end
 
  CashCow.bulk_charge_cards(cards_and_amounts)
 # ...send receipts and other stuff...
 end
 end

We haven’t updated our specs yet. If we run them now, the message expectation will fail; the code is calling a different API than we’ve specified.

We’ve got a bigger problem, though. Our old specs don’t stub out the bulk_charge_cards method. We’ll end up sending a real charge request—something we don’t want to do from a unit spec!

The partial double made it easy for our code to perform a costly operation for real, even though the entire point of the test double was to prevent this problem.

We can easily avoid this problem by using a pure or verifying test double instead. To do so, we’ll add a new argument to process_subscriptions that lets us inject a billing object other than CashCow:

15-using-test-doubles-effectively/09/subscription_service/lib/recurring_payment.rb
 class​ RecurringPayment
 def​ self.process_subscriptions(subscriptions, ​bank: ​CashCow)
  subscriptions.each ​do​ |subscription|
  bank.charge_card(subscription.credit_card, subscription.amount)
 # ...send receipt and other stuff...
 end
 end
 end

Now, instead of stubbing methods on the real CashCow class, our specs can just create a verifying double and pass it in instead:

15-using-test-doubles-effectively/09/subscription_service/spec/recurring_payment_spec.rb
 RSpec.describe RecurringPayment ​do
 it​ ​'charges the credit card for each subscription'​ ​do
  card_1 = Card.new(​:visa​, ​'1234 5678 9012 3456'​)
  card_2 = Card.new(​:mastercard​, ​'9876 5432 1098 7654'​)
 
  subscriptions = [
  Subscription.new(​'John Doe'​, card_1, 19.99),
  Subscription.new(​'Jane Doe'​, card_2, 29.99)
  ]
 
  bank = class_double(CashCow)
 expect​(bank).to receive(​:charge_card​).with(card_1, 19.99)
 expect​(bank).to receive(​:charge_card​).with(card_2, 29.99)
 
  RecurringPayment.process_subscriptions(subscriptions, ​bank: ​bank)
 end
 end

This kind of substitution is only feasible if you want an entirely fake object. If you find yourself needing to mix real and fake behavior in the same object, consider splitting it into multiple objects, as we did in the previous section.

That said, breaking apart objects will not always improve your design. In Constructing Your Test Environment, the Acme::Config object had one simple job. Splitting it up would not have helped it do its job better, so we stubbed out one configuration method for our specs instead.

Use Partial Doubles Carefully

images/aside-icons/info.png

Our advice to you isn’t “avoid partial doubles,” but rather, “listen to the feedback your doubles are giving you, and know the risks.”

We recommend that you configure your projects with an additional safety check for partial doubles, in case you end up using them. The option is called verify_partial_doubles:[103]

15-using-test-doubles-effectively/09/subscription_service/spec/spec_helper.rb
 RSpec.configure ​do​ |config|
  config.mock_with ​:rspec​ ​do​ |mocks|
  mocks.verify_partial_doubles = ​true
 end
 end

This option will apply the same checks that RSpec uses for pure verifying doubles, providing a bit of extra safety. RSpec will set it for you in your spec_helper.rb if you use rspec --init to bootstrap your project.

While it wouldn’t have prevented the credit-card problem we encountered in this section, verification will at least protect you from a mistyped or out-of-date message expectation. If you accidentally stub a method with the wrong name or wrong number of arguments, RSpec will detect this situation and report a failure.

In this section, we provided the CashCow test double by passing it as a parameter, a form of dependency injection. This technique is one of many different ways to connect each test subject to its environment. We’ll explore some of these options next.