Wrapping a Third-Party Dependency

While high-fidelity fakes can save a lot of trouble, they can still leave your logic coupled to a third-party API. This coupling has a few downsides:

To avoid these pitfalls, you can wrap the dependency—that is, write your own layer that delegates to it internally. Using a wrapper (also known as a gateway or adapter when you’re wrapping an API) gives you a couple of key advantages:

Even though wrapping a dependency minimizes the risk of its interface changing from underneath you, this risk is still present. To address it, you can write a small number of integration specs that test your wrapper against the real library or service.

In the following example, we’re testing an Invoice class that uses the TaxJar Ruby API client to calculate sales tax for a shopping site.[115] The initial version calls TaxJar directly, and then we’ll refactor our class to use a wrapper. Here’s the public interface of the class:

15-using-test-doubles-effectively/21/sales_tax/lib/invoice.rb
 class​ Invoice
 def​ initialize(address, items, ​tax_client: ​MyApp.tax_client)
  @address = address
  @items = items
  @tax_client = tax_client
 end
 
 def​ calculate_total
  subtotal = @items.map(&​:cost​).inject(0, :+)
  taxes = subtotal * tax_rate
  subtotal + taxes
 end
 
 # ...
 end

A new Invoice needs a shipping address and a list of items. To support testing, we’re using dependency injection to pass a real or fake TaxJar implementation into the initializer. The calculate_total method tabulates the total cost of the items in the cart, and then applies the tax rate.

The tax rate lookup seems simple on the surface; once we know the ZIP code we’re shipping to, we should be able to get the correct rate from TaxJar. The logic for doing so is a little convoluted, though:

15-using-test-doubles-effectively/22/sales_tax/lib/invoice.rb
 def​ tax_rate
  @tax_client.rates_for_location(@address.zip).combined_rate
 end

The TaxJar client requires more of us than a single method invocation. First, we call rates_for_location to get an object containing all the applicable tax rates (city, state, and so on). Then, we fetch its combined_rate to get the total sales tax.

This interface provides a lot of flexibility, but we don’t need much of it in our Invoice class. It would be nice to have a simpler API that fits just the needs of our project.

Look what happens when we try to test this class. We end up creating one test double, tax_client, that returns a second double, tax_rate:

15-using-test-doubles-effectively/22/sales_tax/spec/unit/invoice_spec.rb
 require ​'invoice'
 
 RSpec.describe Invoice ​do
 let​(​:address​) { Address.new(​zip: ​​'90210'​) }
 let​(​:items​) { [Item.new(​cost: ​30), Item.new(​cost: ​70)] }
 
 it​ ​'calculates the total'​ ​do
» tax_rate = instance_double(Taxjar::Rate, ​combined_rate: ​0.095)
» tax_client = instance_double(Taxjar::Client, ​rates_for_location: ​tax_rate)
 
  invoice = Invoice.new(address, items, ​tax_client: ​tax_client)
 
 expect​(invoice.calculate_total).to eq(109.50)
 end
 end

Our complex mocking structure (a double that returns a double) has revealed a couple of problems with our project:

In his blog post, “Test Isolation Is About Avoiding Mocks,” Gary Bernhardt recommends that we untangle the object relationships that are made obvious by these kinds of nested doubles.[116] We can follow his advice by writing a SalesTax wrapper that decouples Invoice from TaxJar:

15-using-test-doubles-effectively/23/sales_tax/lib/sales_tax.rb
 require ​'my_app'
 
 class​ SalesTax
  RateUnavailableError = Class.new(StandardError)
 
 def​ initialize(tax_client = MyApp.tax_client)
  @tax_client = tax_client
 end
 
 def​ rate_for(zip)
  @tax_client.rates_for_location(zip).combined_rate
 rescue​ Taxjar::Error::NotFound
 raise​ RateUnavailableError, ​"Sales tax rate unavailable for zip: ​​#{​zip​}​​"
 end
 end

Our goal is to isolate the Invoice class completely from TaxJar—including its specific exception classes. That’s why we transform any Taxjar::Error::NotFound error we see into an error type we have defined.

The rest of the application no longer needs to have any knowledge of TaxJar. If TaxJar’s API changes, this class is the only piece of code we’ll have to update.

When we create a new Invoice, we now pass in the wrapper instead of the original TaxJar class:

15-using-test-doubles-effectively/24/sales_tax/lib/invoice.rb
 def​ initialize(address, items, ​sales_tax: ​SalesTax.new)
  @address = address
  @items = items
  @sales_tax = sales_tax
 end

Here’s what the new, simpler Invoice#tax_rate method looks like:

15-using-test-doubles-effectively/24/sales_tax/lib/invoice.rb
 def​ tax_rate
  @sales_tax.rate_for(@address.zip)
 end

Our unit spec for Invoice is also much easier to understand and maintain. We no longer need to construct a rickety structure of test doubles. A single instance double will do:

15-using-test-doubles-effectively/24/sales_tax/spec/unit/invoice_spec.rb
 require ​'invoice'
 
 RSpec.describe Invoice ​do
 let​(​:address​) { Address.new(​zip: ​​'90210'​) }
 let​(​:items​) { [Item.new(​cost: ​30), Item.new(​cost: ​70)] }
 
 it​ ​'calculates the total'​ ​do
» sales_tax = instance_double(SalesTax, ​rate_for: ​0.095)
 
  invoice = Invoice.new(address, items, ​sales_tax: ​sales_tax)
 
 expect​(invoice.calculate_total).to eq(109.50)
 end
 end

By wrapping the TaxJar API, we’ve improved our code and specs in many ways:

We do need to have some way to detect changes in TaxJar, though. The best place for that is an integration spec for our SalesTax wrapper, which is easy to write because we’ve kept our wrapper thin:

15-using-test-doubles-effectively/24/sales_tax/spec/integration/sales_tax_spec.rb
 require ​'sales_tax'
 
 RSpec.describe SalesTax ​do
 let​(​:sales_tax​) { SalesTax.new }
 
 it​ ​'can fetch the tax rate for a given zip'​ ​do
  rate = sales_tax.rate_for(​'90210'​)
 expect​(rate).to be_a(Float).and be_between(0.01, 0.5)
 end
 
 it​ ​'raises an error if the tax rate cannot be found'​ ​do
 expect​ {
  sales_tax.rate_for(​'00000'​)
  }.to raise_error(SalesTax::RateUnavailableError)
 end
 end

Because this spec tests against the real TaxJar classes, it will correctly fail if there are breaking changes in the API. Although the API should be stable, we expect the tax rate to fluctuate somewhat. That’s why we’re checking against a range rather than an exact value.

As a next step, we could use the VCR gem here to cache responses. We could then run these specs without needing real API credentials—which will be handy on our continuous integration (CI) server. We’d still want to revalidate against the real service periodically, which we can easily do by deleting the VCR recordings.