Testing Tools for API Consumption

Resources

Learning Goals

After this class, a student should be able to:

  • Explain why we don’t want our tests to make real API calls
  • Understand how to stub network requests using WebMock and VCR

Slides

Available here

Mocking Network Requests

When last we met, we got our code working, but our test is making a real API call which is not good. There are many reasons we wouldn’t want to do this:

  1. We could hit API rate limits much faster.
  2. Our test suite will be slower.
  3. If someone working on our team doesn’t have an API key set up, we make it that much harder for them to jump into our code base.
  4. If we ever need to work without WiFi, or if the WiFi is down, or if the API we’re using goes down (for maintenance, for example), we make it impossible to keep working on the app.

Rather than making real HTTP requests, we want to make Mock HTTP Requests.

WebMock

We will be using WebMock to mock our HTTP requests. As always, you should open up the docs to get an idea of how it works.

Install the Gem

Looking at the “Installation” section of the docs, we can see we need to gem install webmock, but since we’re using Bundler we can add it to our Gemfile which handles our gem installation. Add gem 'webmock' to the :test block of your Gemfile. DO NOT add it to the :development, :test block (more on that in a second). Run bundle install.

Finally, we can see a section for “RSpec” in the Installation instructions. This tells us to add require 'webmock/rspec' to our spec/spec_helper. Do that now.

Now, when we run our tests we can see a big error message:

WebMock::NetConnectNotAllowedError:
      Real HTTP connections are disabled. Unregistered request: GET https://api.propublica.org/congress/v1/members/house/CO/current.json with headers {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'User-Agent'=>'Faraday v0.15.4', 'X-Api-Key'=>'opyjcKdEUKllG8P5V15kv3yKKbx1KwkGQwXbfCF3'}

      You can stub this request with the following snippet:

      stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
        with(
          headers: {
         'Accept'=>'*/*',
         'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
         'User-Agent'=>'Faraday v0.15.4',
         'X-Api-Key'=>'opyjcKdEUKllG8P5V15kv3yKKbx1KwkGQwXbfCF3'
          }).
        to_return(status: 200, body: "", headers: {})

      ============================================================

This means it’s working! WebMock not only allows us to mock real HTTP requests, but also prevents us from making real HTTP requests. While this is good for our test suite (which we run very frequently), we do want to see the real requests being made at some point, so we want to allow HTTP requests in development. This is why we only added the gem to the :test block of our Gemfile and not :development, :test.

Stubbing the Request

Looking at the docs, we can see some examples of how to stub requests. Let’s add one to our test:

scenario "user submits valid state name" do
    stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
        to_return(status: 200, body: "")
    # As a user
    # When I visit "/"
    visit '/'

Now when we run the tests, we get JSON::ParserError: 743: unexpected token at ''. The stack trace points us to the SearchController on the line where we do json = JSON.parse(response.body, symbolize_names: true). If we look at the stub we just put in the test, we are returning an empty body, so it makes sense that we’re getting an error when trying to parse the response body as JSON.

We need to replace the empty body with an actual JSON response. We could copy and paste a body right into this test, but then our test file would get quite messy. What we’ll do instead is make a spec/fixtures directory with a file that we can read:

mkdir spec/fixtures
touch spec/fixtures/members_of_the_house.json

And update our test:

json_response = File.read('spec/fixtures/members_of_the_house.json')
stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
  to_return(status: 200, body: json_response)

We’re still returning an empty body because our file is empty, so let’s add some actual JSON data to that file. Use Postman to hit the ProPublica API to get a JSON response and copy and paste it in. Your test should be passing once again.

If this is really working, we should be able to turn off our WiFi and see the test is still working.

VCR

Another handy tool for mocking these requests is VCR. You can think of it as an extension of WebMock. We will still be stubbing requests, but now rather than manually creating the mock JSON response, VCR will allow us to make one real HTTP request the first time, record its response, and use that response as the stub for future requests. VCR refers to these recorded responses as cassettes.

Setup

First, add gem 'vcr' to the :test block of your Gemfile and bundle install.

Then, add this at the bottom of your rails_helper:

VCR.configure do |config|
  config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
  config.hook_into :webmock
end

In the first line of the block, we tell VCR where we want to store the the cassettes. We are making use of the spec/fixtures folder we already created.

The second line tells VCR what library it should use for intercepting these requests, which will be WebMock. So we are still using WebMock, but VCR is adding additional functionality for recording responses.

Go back into the test and comment out the lines where we stubbed the request with WebMock:

scenario "user submits valid state name" do
    # json_response = File.read('spec/fixtures/members_of_the_house.json')
    # stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
    #   to_return(status: 200, body: json_response)
    # As a user
    # When I visit "/"
    visit '/'

Run the tests and you should see VCR::Errors::UnhandledHTTPRequestError:. That means it’s working!

Stubbing the Request

In order to use VCR, we wrap our test in a VCR.use_cassette block:

scenario "user submits valid state name" do
  # json_response = File.read('spec/fixtures/members_of_the_house.json')
  # stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
  #   to_return(status: 200, body: json_response)
  # As a user
  # When I visit "/"

  VCR.use_cassette('propublica_members_of_the_house_for_co') do
    visit '/'

    select "Colorado", from: :state
    # And I select "Colorado" from the dropdown
    click_on "Locate Members of the House"
    # And I click on "Locate Members from the House"
    expect(current_path).to eq(search_path)
    # Then my path should be "/search" with "state=CO" in the parameters
    expect(page).to have_content("7 Results")
    # And I should see a message "7 Results"
    expect(page).to have_css(".member", count: 7)
    # And I should see a list of 7 the members of the house for Colorado

    within(first(".member")) do
      expect(page).to have_css(".name")
      expect(page).to have_css(".role")
      expect(page).to have_css(".party")
      expect(page).to have_css(".district")
    end
    # And they should be ordered by seniority from most to least
    # And I should see a name, role, party, and district for each member
  end
end

The string we passed to use_cassette is an identifier for the cassette, so it doesn’t really matter what you pass it.

Run your tests and they should be passing. If you look under spec/fixtures/vcr_cassettes you should see a .yml file that contains your recorded response.

Filtering Sensitive Data

If you look closely in that .yml file you can see our API key in there. We will be pushing these cassettes to GitHub, so we don’t want the actual API key to be recorded for the same reasons we don’t want our application.yml file pushed and we don’t want to hardcode the API key in our code. We will use a VCR option to replace the actual API key with a placeholder. Open up your rails_helper.rb and add another line to the VCR configuration:

VCR.configure do |config|
  config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
  config.hook_into :webmock
  config.filter_sensitive_data('<PROPUBLICA_API_KEY>') { ENV['PROPUBLICA_API_KEY'] }
end

Then, delete your VCR cassettes directory:

rm -rf spec/fixtures/vcr_cassettes

Run your test suite again, and you should see a new VCR cassette in the vcr_cassettes directory. Open it up and confirm that your api key is now being replaced with <PROPUBLICA_API_KEY>.

You will need to add a filter_sensitive_data block for EACH thing you want to filter. If you’re building an app using several API keys, make sure you add a filter for each thing in your config/application.yml that you want to have hidden!

Using RSpec Metadata

VCR has a handy feature that allows us to use the names of our tests to name cassettes rather than having to manually wrap each test in a VCR.use_cassette block and give the cassette a name. Add one more line to your VCR config block:

VCR.configure do |config|
  config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
  config.hook_into :webmock
  config.filter_sensitive_data('<PROPUBLICA_API_KEY>') { ENV['PROPUBLICA_API_KEY'] }
  config.configure_rspec_metadata!
end

Now in our tests, we can delete the VCR.use_cassette block and tell the test to use VCR by passing it :vcr:

scenario "user submits valid state name", :vcr do
    # json_response = File.read('spec/fixtures/members_of_the_house.json')
    # stub_request(:get, "https://api.propublica.org/congress/v1/members/house/CO/current.json").
    #   to_return(status: 200, body: json_response)
    # As a user
    # When I visit "/"

    visit '/'

    select "Colorado", from: :state
    # And I select "Colorado" from the dropdown
    click_on "Locate Members of the House"
    # And I click on "Locate Members from the House"
    expect(current_path).to eq(search_path)
    # Then my path should be "/search" with "state=CO" in the parameters
    expect(page).to have_content("7 Results")
    # And I should see a message "7 Results"
    expect(page).to have_css(".member", count: 7)
    # And I should see a list of 7 the members of the house for Colorado

    within(first(".member")) do
      expect(page).to have_css(".name")
      expect(page).to have_css(".role")
      expect(page).to have_css(".party")
      expect(page).to have_css(".district")
    end
    # And they should be ordered by seniority from most to least
    # And I should see a name, role, party, and district for each member
  end

Run your tests again and you’ll notice a new directory and file in your vcr_cassettes directory that matches the names of the blocks in the test. Now when we want a test to use VCR, we just have to pass it :vcr and we’re good to go. Much easier!

But … manually deleting VCR cassettes is a pain!!!

Thankfully the VCR team have come up with a way to set an expiration on our VCR cassettes, and we can do it one of two ways (or both)

On a per-cassette level, we can set it up like this:

VCR.use_cassette('name_of_cassette', re_record_interval: 7.days) do
  # test code goes here
end

There’s no easy way to configure this on tests which use the :vcr flag, though. One way would be for one test to use the :vcr flag, and another test which makes the same API call to use the VCR.use_cassette() setting above. When the test executes which has the re_record_interval option set to a value, it may ‘expire’ cassette and re-record it if the cassette passes that threshold.

We can also set a global configuration which will apply to all VCR-enabled tests, including those using the :vcr flag, but changing our spec/rails_helper.rb configuration slightly:

VCR.configure do |config|
  config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
  config.hook_into :webmock
  config.filter_sensitive_data('DONT_SHARE_MY_PROPUBLIC_SECRET_KEY') { ENV['PROPUBLICA_KEY'] }
  config.default_cassette_options = { re_record_interval: 7.days }
  config.configure_rspec_metadata!
end

This example uses a “default cassette options” flag, setting a re-record interval of 7 days for all cassettes. You can still override this on individual tests which use VCR.use_cassette(), so you could set a general flag of, say, 30.days but a particular test could be set to 7.days instead to expire earlier.

Checks for Understanding

  • What are some reasons we don’t want our tests to make real API calls?
  • What does WebMock do?
  • What does VCR do?
  • Why don’t we want VCR to record our API key?
  • How are WebMock and VCR similar? different?

Lesson Search Results

Showing top 10 results