This post is part of an ongoing series about Unit Testing.

Introduction

We’re building a simple command-line application that can do two things:

  1. Convert an amount of money from one currency to another.

  2. Show a list of all currencies supported by the application.

In the last post, we fleshed out the Exchange class by defining an interface to an external API. We did this in order to decouple our application from the specific details of the API we ultimately choose to use.

Now it’s time to choose an API and implement that interface.

The API

After looking at a few options, I decided to start with the Open Exchange Rates API. It’s free for the limited use we need, and looks relatively simple to use.

It looks like there are two endpoints we’ll need:

  • /latest.json gives us back the current exchange rates between all supported currencies and a base currency (USD). If we use a bit of simple math as outlined in the documentation, we can use these rates to convert an amount from one currency to another.

  • /currencies.json returns a list of all supported currencies. Each entry contains the currency code and a description of the currency.

If we later decide to purchase an enterprise license, there’s another endpoint available (/convert) that will perform the conversion for us. Fortunately, the design of our abstract API interface will easily support that change.

Testing the API

We need to figure out how we’ll test our new OpenExchangeRates API class.

I won’t go through all of the options here, but there good blog posts that discuss some alternatives:

In general, we don’t want to rely on the external service when running our tests. It would slow our tests down significantly and also make them susceptible to any service outages. Also, the external service will constantly be providing new data as the exchange rates are updated; it’d be difficult to write a useful test that allows for that.

On the other hand, we want to be sure our system works properly with the external service, especially with its data format.

We’ll use VCR to help us. The first time we run the tests, VCR will download and save the actual response from the external service. After that, it’ll play back that same response without hitting the external service.

The Tests

With that decided, let’s start test-driving our concrete API interface.

Currency Conversion

Because the free version of the external service we’ve chosen only returns a set of exchange rates, we have to do a bit of math to implement the convert method required by our abstract API.

At this point, we might be tempted to modify our API’s interface to match that of the external service and do the math in our Exchange object, but that would be a bad idea. Not all APIs will work this way. By writing our API interface in terms of the services our application needs, we can hide these kinds of details from the main part of our application.

I often start with a “test list” as described by Kent Beck in his TDD By Example book. Sometimes I’ll write my list on paper or as comments in the test class, but in RSpec it’s just as easy to write the test list in the form of pending specs.

Test List
describe "converting from one currency to another" do
it "performs a no-op conversion"
it "converts from the base currency"
it "converts to the base currency"
it "converts from one non-base currency to another"
end

Let’s start with the first pending spec, a very simple conversion test. We’ll convert an amount from USD to USD. This is not much of a test, but it does allow us to get some structure in place.

No-op Conversion
module Currencyfx
module Apis
RSpec.describe OpenExchangeRates do
subject(:api) { OpenExchangeRates.new }
describe "converting from one currency to another",
vcr: { cassette_name: "open_exchange_rates/rates" } do
it "performs a no-op conversion" do
expect(api.convert(100, "USD", "USD")).to eq(100)
end
end
end
end
end

I decided to be explicit about VCR cassette names instead of taking advantage of VCR’s ability to name cassettes based on the RSpec metadata. There are only two endpoints to hit, and we’re not sending any additional arguments. We should be able to re-use the same cassette for all of our conversion specs. This test is relatively trivial to pass and doesn’t actually need to hit the server at all; it can simply return the amount that was passed in.

Let’s flesh out a spec that makes us actually look at the exchange rates. The simplest case is to convert an amount from the base currency to another currency; to pass that test, we multiply the amount by the exchange rate for the target currency.

Converting From the Base Currency
it "converts from the base currency" do
expect(api.convert(100, "USD", "EUR")).to be_within(0.005).of(0)
end

Since we don’t know what the actual exchange rate is going to be, we can use an expected value of 0 initially. After running the test, we can fill in the actual value based on the failure message after looking at the cassette that VCR saved to make sure it looks right.

I’m using the be_within matcher here to avoid floating point roundoff issues.

Filling In the Correct Value
it "converts from the base currency" do
expect(api.convert(100, "USD", "EUR")).to be_within(0.005).of(88.76)
end

The next easiest spec to write is converting an amount to the base currency. We’ll divide the amount by the exchange rate for the source currency.

Converting To the Base Currency
it "converts to the base currency" do
expect(api.convert(100, "EUR", "USD")).to be_within(0.005).of(112.66)
end

Since we already knew the exchange rate from the previous test, we can figure out the answer by hand and plug it in.

The last pending spec is to convert from one non-base currency to another.

Full Conversion
it "converts from one non-base currency to another" do
expect(api.convert(100, "CAD", "EUR")).to be_within(0.005).of(72.07)
end

For this one, we need to multiply by the target currency’s exchange rate and divide by the source currency’s exchange rate to get the answer. Again, we can look at the data in the stored cassette and perform the conversion by hand, then made sure the code gives us back the right answer.

Spooky Action at a Distance

As I read through these tests, I notice the magic numbers everywhere. All of the expected answers appear out of nowhere and the test doesn’t say much about where they come from.

This is a test of the interface to an external service so we do want to be sure that we’re communicating properly with that service, but these specs are not very understandable on their own.

Another issue is that all of these tests will likely fail if we ever decide to regenerate our cassettes. We may want to do that to be sure the external service’s response formats haven’t changed. However, updated cassettes will almost certainly have different exchange rates and that will make our tests fail. That’s not good.

VCR allows us to use ERB on the cassette files, so let’s give that a try. We can edit the rates cassette and replace a few of the rates with ERB template strings. We can then modify the specs to inject known exchange rates into the VCR response and update the expected conversion values accordingly.

Injecting Exchange Rates
describe "converting from one currency to another",
vcr: {
cassette_name: "open_exchange_rates/rates",
erb: { eur: 0.8, cad: 1.1 }
} do
it "performs a no-op conversion" do
expect(api.convert(100, "USD", "USD")).to eq(100)
end
it "converts from the base currency" do
expect(api.convert(100, "USD", "EUR")).to be_within(0.005).of(80)
end
it "converts to the base currency" do
expect(api.convert(100, "EUR", "USD")).to be_within(0.005).of(125)
end
it "converts from one non-base currency to another" do
expect(api.convert(100, "CAD", "EUR")).to be_within(0.005).of(72.73)
end
end

We can now see where the magic numbers are coming from, which is an improvement. But we still have the problem of our specs breaking when we next decide to refresh the cassettes. I don’t have a good solution to this problem, other than to make a note about what changes to make to the cassette when it is refreshed.

Currency List

The spec for the currency_list function is much simpler.

Current List
describe "retrieving the currency list",
vcr: { cassette_name: "open_exchange_rates/currencies" } do
it "returns a hash of currency codes and descriptions" do
expect(api.currency_list).to include("USD" => "United States Dollar", "EUR" => "Euro")
end
end

Note that we’re not testing the entire result. We’re testing the minimum amount that will give us confidence that we’re correctly talking to the external service, and that we’re returning the right kind of result. RSpec’s include matcher is perfect for this; as long as the result is a Hash that contains at least the key/value pairs we’re asking for, the test will pass.

The Jasmine testing framework in JavaScript has some similar matchers that I’ve written about before.

In general, I try not to over-constrain the code I’m testing; I don’t want my tests breaking all the time due to irrelevant details. This is a good example of that principle. We can test a couple of currencies that aren’t likely to change in the near future and not worry about our test being too fragile.

Conclusion

We’ve now worked our way to the inside of our system, one layer at a time. If we’ve done everything right, we should now have a working application. But we haven’t yet tested all of the pieces as an integrated whole.

As you may recall, we started out by writing some Cucumber tests. Our next step is to get those Cucumber tests running to see if we’ve got our application working end-to-end. That will be the topic of the next post.