Subscribe for only $15 to access all of our content

#103: Writing Tests with ExVCR

Elixir 1.8

Phoenix 1.4

ExVCR 0.10.4

Source code on GitHub


Here here we have a module that fetches the current price of a cryptocurrency. Let’s see it in action. We’ll start an iex session with our project. We see we get an OK tuple returned if we’re able get the price successfully and an error tuple if it’s not able to fetch the price.

$ iex -S mix
Interactive Elixir (1.8.1)
> Teacher.Coins.CoinService.fetch_current_price("bitcoin")
{:ok, 9550.597079822928}
> Teacher.Coins.CoinService.fetch_current_price("elixircoin")
{:error, "No coin found"}

In this episode let’s add some tests for our fetch_current_price function. Because this functions calls to the CoinCap API to get the current price or a cryptocurrency we need to determine how we want to handle this in our test. Let’s use the ExVCR package to help test this. ExVCR allows us to record HTTP requests and responses and then replay them in our tests. This way we wont hit the API every time we run our test and we get the full response we get in production.

To start let’s go to Hex and get the exvcr package. Then let’s open our mixfile and add it to our list of dependencies.

mix.exs

...
{:exvcr, "~> 0.10.4", only: :test},
...

Then let’s go to the command line and install it.

$ mix deps.get
...
New:
  exactor 2.2.4
  exjsx 4.0.0
  exvcr 0.10.4
  jsx 2.8.3
  meck 0.8.13

Let’s create our test file. Since our CoinService module is in the “coins” directory, let’s match that in our “test” directory. We’ll create a new “teacher” directory and then a “coins” directory inside of it. Then we’ll create our new test file coin_service_test.exs and define our test module, using ExUnit.Case.

Now let’s setup our test to use ExVCR to mock the API call. We’ll include use ExVCR.Mock and by default ExVCR uses ibrowse, but we are already using the HTTPoison package, which uses Hackney. So instead of bringing in another dependency let’s configure ExVCR to use Hackney. Then let’s add a setup block and start HTTPoison from it.

We can now start writing our first test. We’ll add a describe block for CoinService.fetch_current_price, then let’s test that we are able to fetch the current price of a cryptocurrency. We’ll call our function to get the current price of a Bitcoin, pattern matching on the OK tuple that’s returned and then assert that the returned price matches the current price.

To get the current price, let’s run the fetch_current_price function and use the current price that’s returned. Because the price returned from this function changes depending on what’s returned from the API, we need to tell ExVCR to record the API request and response. To do that we’ll wrap the inside of our test with the use_cassette macro we get from ExVCR. This takes a name, so let’s call the cassette for this test price_fetch_success. The first time we run our test, this will call the API and record the request and response as a cassette. And then once the cassette is recorded every subsequent time we run our test it will then use that cassette instead of calling the API.

test/teacher/coins/coin_service_test.exs

defmodule Teacher.Coins.CoinServiceTest do
  use ExUnit.Case
  use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney

  setup do
    HTTPoison.start
    :ok
  end

  describe "CoinService.fetch_current_price/1" do
    test "current price fetched" do
      use_cassette "price_fetch_success" do
        {:ok, price} = Teacher.Coins.CoinService.fetch_current_price("bitcoin")
        assert price == 10030.066907869972
      end
    end
  end
end

Now that we have our test, let’s go to the command line and run it.

$ mix test test/teacher/coins/coin_service_test.exs
...

Our test fails here because the price that was returned from the API is different that what we specified in our test.

Let’s check to see if our cassette was created. Looking at our project we see there’s a new directory named “fixtures/vcr_cassettes”. This is where our ExVCR cassettes are stored by default. If we open our “fetch_price_success.json” cassette we can see some data about the request, like the URL, the response body, headers, and status code.

Let’s grab the price that was recorded from the test and then let’s update our test to use it. Now if we go to the command line and run our test again - great, it passed. And now every time we run this test instead of making a new call to the API, the response recorded in the cassette is used.

$ mix test test/teacher/coins/coin_service_test.exs
.

Finished in 0.6 seconds
1 test, 0 failures

Randomized with seed 736052

Now let’s add another test. We’ll go back to our coin_service_test.exs module. And we’ll add another test for instances when this function is called with a coin that doesn’t exist.

Since we know this will call the API, let’s create a cassette named “price_fetch_error” for the test to use. Then let’s call our function again with a cryptocurrency that doesn’t exist pattern matching on the {:error, ...} that’s returned and we’ll assert that we get the “No coin found” message returned.

test/teacher/coins/coin_service_test.exs

defmodule Teacher.Coins.CoinServiceTest do
  use ExUnit.Case
  use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney

  setup do
    HTTPoison.start
    :ok
  end

  describe "CoinService.fetch_current_price/1" do
    test "current price fetched" do
      use_cassette "price_fetch_success" do
        {:ok, price} = Teacher.Coins.CoinService.fetch_current_price("bitcoin")
        assert price == 10030.066907869972
      end
    end

    test "invalid coin used" do
      use_cassette "price_fetch_error" do
        {:error, msg} = Teacher.Coins.CoinService.fetch_current_price("elixircoin")
        assert msg == "No coin found"
      end
    end

  end
end

The we’ll go back to the command line and let’s run only our new test, which we can do by adding the line that our test starts on.

$ mix test test/teacher/coins/coin_service_test.exs:18
Excluding tags: [:test]
Including tags: [line: "18"]

.

Finished in 0.5 seconds
2 tests, 0 failures, 1 excluded

Randomized with seed 598613

It looks like our test passed, but let’s check that a new cassette was recorded. Let’s open the “fixtures/vcr_cassettes” directory and great, our “price_fetch_error.json” cassette was recorded. Now let’s look at some other features of VCR.

We can use $ MIX_ENV=test mix vcr --help to list all the different tasks provided. There’s mix vcr to list all of the cassettes, mix vcr.delete to delete a given cassette, mix vcr.check to check the cassette use for a given test, and mix vcr.show to show the contents of a given cassette. Let’s use mix vcr.delete to delete our “price_fetch_error” cassette.

$ MIX_ENV=test mix vcr.delete price_fetch_error
Deleted price_fetch_error.json.

ExVCR has a few different options as well, which you can find in the README. Let’s try one configuration option.

Back in our coin_service_test.exs let’s change the directory that we store the cassettes for these tests in. In our setup block let’s add a call to ExVCR.Config.cassette_library_dir passing in the new directory we want these cassettes stored in. Let’s use a directory named “coin_cassettes”.

test/teacher/coins/coin_service_test.exs

...
setup do
  HTTPoison.start
  ExVCR.Config.cassette_library_dir("fixture/coin_cassettes")
  :ok
end
...

Then let’s go back to the command line and delete our other cassette.

$ MIX_ENV=test mix vcr.delete price_fetch_success
Deleted price_fetch_success.json.

Then run our tests again to record new ExVCR cassettes.

$ mix test test/teacher/coins/coin_service_test.exs
.

  1) test CoinService.fetch_current_price/1 the current price is fetched (Teacher.Coins.CoinServiceTest)
     test/teacher/coins/coin_service_test.exs:12
     Assertion with == failed
     code:  assert price == 10024.177762984644
     left:  10028.88666677563
     right: 10024.177762984644
     stacktrace:
       test/teacher/coins/coin_service_test.exs:15: (test)



Finished in 2.4 seconds
2 tests, 1 failure

Randomized with seed 862708

Our test checking that the price is returned fails, which is expected since we are calling the API directly and the price has changed.

Let’s check the cassette location is correct - and great the new “/coin_cassettes” directory has been created and our cassettes are there.

Let’s go back to the command line and grab the new price. Then we’ll go back to our test and update it and now if we run our tests again - they pass.

$ mix test test/teacher/coins/coin_service_test.exs
..

Finished in 1.2 seconds
2 tests, 0 failures

Randomized with seed 855551

More Episodes

Alchemist's Edition

#104: Generating RSS Feeds with Elixir

In this episode we’ll take an existing Elixir Phoenix application and build an Atom feed for it. To build the feed we’ll use the Atomex package.

Watch episode
Alchemist's Edition

#102: OTP backed Web Application Part 4

In the final part of this series we’ll use Phoenix Channels to improve the UI so that our coin tracker page updates automatically when a new cryptocurrency price is found.

Watch episode
Alchemist's Edition

#101: OTP backed Web Application Part 3

In part 3 we’ll create a schema for our coin data so that we don’t lose data when we restart our app.

Watch episode