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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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:
| 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:
The wrapper API is simpler to call, because it omits details we don’t need.
We can switch to a different third-party sales tax service by changing just one class, leaving the rest of our project intact.
Because we control the wrapper’s interface, it won’t change without our knowledge.
The unit specs for Invoice won’t break if the TaxJar API changes.
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:
| 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.