How to test HTTP requests in Elixir with ExVCR

How to test HTTP requests in Elixir with ExVCR

As your app grows so does its integrations base. At some point, you may need to consume API from third-party services. Today you'll learn how to test them properly with the exvcr library.

Table of contents

    Demo app

    To illustrate why testing 3-rd party services is a problem, let's create a simple wrapper within the sample Address Converter app that will return the latitude and longitude of a geographical object. We're going to consume https://nominatim.openstreetmap.org free API for this purpose.

    For simplicity, let's assume that the Address Converter app is already created. To interact easily with external API let's use the HTTPoison and Jason libraries.

    In mix.exs:

    defp deps do
      ...
      {:jason, "~> 1.0"},
      {:httpoison, "~> 1.6"}
      ...
    end

    and after that:

    mix deps.get

    The next step is to implement Nominatim API wrapper. In lib/address_converter/nominatim.ex file, let's add few lines of code:

    defmodule AddressConverter.Nominatim do
      @base_url "https://nominatim.openstreetmap.org/search?format=json&q="
      @headers [{"Content-Type", "application/json"}]
    
      def fetch_coordinates(query) do
        with %{ body: body } <- HTTPoison.get!(@base_url <> query, @headers),
             response <- Jason.decode!(body) do
          response
          |> Enum.map(&%{ lat: &1["lat"], lon: &1["lon"] })
          |> Enum.at(0)
        else
          _ -> %{}
        end
      end
    end

    As you can see we are fetching data from https://nominatim.openstreetmap.org with proper query and JSON Content-Type header to easily interact with a response later on.

    Thanks to the Jason library we can decode the returned body and use Enum.map to map each result's latitude and longitude. As nominatim.openstreetmap.org lists all possible geographical places that match the given query, there might a lot of mapped coordinates. For simplicity, let's assume that we only need the first one, and that's exactly why we use Enum.at(0).

    Quick demo of how it works:

    iex> AddressConverter.Nominatim.fetch_coordinates("Poznan, Poland")
    %{lat: "52.4082663", lon: "16.9335199"}

    Testing external API problem

    As our wrapper is ready to rock the world, we can proceed to the test phase.

    In test/address_converter/nominatim_test.exs let's create the following test case scenario:

    defmodule AddressConverter.NominatimTest do
      use ExUnit.Case, async: true
    
      alias AddressConverter.Nominatim
    
      describe "fetch_coordinates/1" do
        test "for given query it should return proper coordinates" do
          cords = %{lat: "52.4082663", lon: "16.9335199"}
    
          assert Nominatim.fetch_coordinates("Poznan, Poland") == cords
        end
      end
    end

    It's as easy as checking whether the coordinates match proper values. Let's check it out in action:

    $ mix test
    
    .
    
    Finished in 0.4 seconds
    1 test, 0 failures

    Side note: If you want to make sure that our code is making a real HTTP request, turn off the internet connection and run this test once again.

    Ok, our test works as intended. So what's the problem?

    Well, with this solution, each time we run this test it's executing a real HTTP request to fetch the data. It's not a good idea, and there are at least five reasons why:

    • request/response cycle might take some time, you don't want to slow down your tests,
    • it might not be a free API, you may pay for requests that are being executed during tests,
    • some APIs have rate limits,
    • you need an internet connection all the time even if you didn't change test and code,
    • response for a given request in most cases should always be the same (we can mock it).

    Meet exvcr library

    In this case, the solution to our problem is to record the response in a file. We don't have to do it manually, there is a lib for that, and it's called exvcr.

    Let's add this lib to deps:

    defp deps do
      ...
      {:exvcr, "~> 0.11", only: :test}
      ...
    end

    As you can see we're only adding it to test dependencies, as most likely you'll not gonna use it in the dev/prod environment.

    Let's fetch our new dependency:

    mix deps.get

    If you take a look at the documentation, you'll notice that currently exvcr works well with three HTTP clients:

    • hackney,
    • httpc,
    • ibrowse.

    It means that if you're trying to test a code that makes an HTTP request using one of these clients under the hood then you're good to go. In our case, we're using HTTPoison which is built on top of Hackney.

    Here is an updated test code that takes leverage of exvcr:

    defmodule AddressConverter.NominatimTest do
      use ExUnit.Case, async: true
      use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
    
      alias AddressConverter.Nominatim
    
      setup do
        ExVCR.Config.cassette_library_dir("fixture/vcr_cassettes")
        :ok
      end
    
      describe "fetch_coordinates/1" do
        test "for given query it should return proper coordinates" do
          use_cassette "nominatim" do
            response = %{lat: "52.4082663", lon: "16.9335199"}
    
            assert Nominatim.fetch_coordinates("Poznan, Poland") == response
          end
        end
      end
    end

    Let's break it down.

    First, we use the ExVCR.Mock module with a proper adapter. As already mentioned, HTTPoison uses Hackney under the hood:

    use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney

    Next, we need to configure ExVCR cassettes path:

    setup do
      ExVCR.Config.cassette_library_dir("fixture/vcr_cassettes")
      :ok
    end

    Once we run the test, it'll create a file with recorded HTTP request/response data and save it in the fixture/vcr_cassettes folder. Can we choose the name for this file? We can, and that's exactly what happens here:

    use_cassette "nominatim" do
      ...
    end

    use_cassette wraps the block of code to ensure that all HTTP requests and responses will be saved into the nominatim file cassette.

    Ok, since we're now familiar with ExVCR and how it can be used within a test case scenario let's run our test:

    $ mix test
    
    .
    
    Finished in 0.7 seconds
    1 test, 0 failures

    It works! As you should notice, there is a new file in your repo - fixture/vcr_cassettes/nominatim.json:

    [
      {
        "request": {
          "body": "",
          "headers": {
            "Content-Type": "application/json"
          },
          "method": "get",
          "options": [],
          "request_body": "",
          "url": "https://nominatim.openstreetmap.org/search?format=json&q=Poznan, Poland"
        },
        "response": {
          "binary": false,
          "body": ..., // The body is a bit long to paste it here
          "headers": {
            "Server": "nginx",
            "Date": "Thu, 29 Oct 2020 17:51:44 GMT",
            "Content-Type": "application/json; charset=UTF-8",
            "Transfer-Encoding": "chunked",
            "Connection": "keep-alive",
            "Keep-Alive": "timeout=20",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "OPTIONS,GET"
          },
          "status_code": 200,
          "type": "ok"
        }
      }
    ]

    In the end, ExVCR runs a real HTTP request to save request and response data, and then when you run the test again, it's trying to match the pattern of request to deliver response without performing HTTP request again. If it finds a match, it returns a saved response. If there is no match, again, a real HTTP request will be performed to save data into a cassette file.

    To make sure that our test doesn't depend on internet connection anymore (so there is no real HTTP request), let's turn it off and run our test once again:

    $ mix test
    
    .
    
    Finished in 0.7 seconds
    1 test, 0 failures

    Great! This is how you can improve tests that depend on external services with the ExVCR cassette mechanism. There is more configuration stuff in this library, as well as little details that you may want to use. Makes sure to check the documentation.

    FAQ

    What is ExVCR and how does it help in testing Elixir applications?

    ExVCR is a library for Elixir that records and replays HTTP requests, making it easier to test applications by mocking external API calls.

    How does ExVCR work with Elixir's HTTP clients?

    ExVCR supports HTTP clients like Hackney, HTTPc, and ibrowse, allowing seamless integration with Elixir's HTTP request libraries, such as HTTPoison.

    How can developers use ExVCR in their Elixir projects?

    Developers can add ExVCR to their project dependencies and use it to mock HTTP requests in their tests, thus avoiding real API calls.

    What are the benefits of using ExVCR in Elixir tests?

    Benefits include faster tests, reduced external dependencies, and avoiding issues with API rate limits and internet connectivity.

    What steps are involved in setting up ExVCR for Elixir tests?

    Setting up involves adding ExVCR to the project's dependencies, configuring cassettes, and wrapping HTTP requests in test cases with ExVCR cassettes.

    Can ExVCR be used with any Elixir application?

    ExVCR is best used in applications that make external HTTP requests and need repeatable, consistent test results without real network calls.

    Are there any limitations when using ExVCR in Elixir applications?

    While ExVCR simplifies testing by mocking API calls, it may not cover all edge cases or simulate real-world network issues.

    Szymon Soppa Web Developer
    Szymon Soppa Curiosum Founder & CEO

    Read more
    on #curiosum blog