VCRpy

Testing integrations with external APIs is fiddly: their documentation might be out of date, they change over time, and at some point you want to actually try interacting with the service manually. You want to fire real HTTP requests to their (hopefully) test or staging environment.

You want to see how it behaves, you want to play around, you want to look for edge cases. You want to test authentication.

When writing tests for the integration, we use mocks - mocks that have been written based on the real responses of the API. This is good, but time-consuming. And if we are writing mocks based on real responses, why not just record the real responses and use them?


# Enter VCR

There is a great tool for recording & replaying HTTP requests: vcrpy. It has a nice pytest plugin too.


At its simplest, it’s just a decorator:

@pytest.mark.vcr()
def test_paginate_puppies_integration(self):
    """
    Integration test that calls the ecommplatform API,
    and records the real response to use for testing.

    Responses are saved in 'cassettes' in ACME_importers/tests/cassettes.

    In order to make the test actually call the API via HTTP
    (to make sure there were no breaking changes, for instance), 
    delete the corresponding cassette, and re-run the test.
    """

    api = ecommplatformAPI({
        'URL': URL,
        'Token': TOKEN,
    })
    puppy_count = api.get_count(resource='puppies').get('count')

    records = api.get_all_puppies()

    # Test that pagination returns all the puppies that we have
    assert len(records) == puppy_count

# Cassettes

It records responses in so-called “cassettes”. These are YAML files that contain recorded interactions.

They look something like this:

- request:
    body: null
    headers:
      Accept: ['*/*']
      Accept-Encoding: ['gzip, deflate']
      Connection: [close]
      User-Agent: [python-requests/2.18.4]
      X-ecommplatform-Access-Token: [xyz]
    method: GET
    uri: https://ACME-test-store.myecommplatform.com/admin/api/2019-10/puppies/count.json
  response:
    body: {string: !!python/unicode '{"count":1996}'}
    headers:
      cf-cache-status: [DYNAMIC]
      cf-ray: [5482708b7d49ce1f-LHR]
      connection: [close]
      content-length: ['13']
      content-type: [application/json; charset=utf-8]
      date: ['Fri, 20 Dec 2019 14:52:21 GMT']
      expect-ct: ['max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"']
      set-cookie: ['__cfduid=df6e9c7563f14d40c4eaf9c7bbfbc281b1576853541; expires=Sun,
          19-Jan-20 14:52:21 GMT; path=/; domain=.myecommplatform.com; HttpOnly; SameSite=Lax']
      strict-transport-security: [max-age=7889238]
      transfer-encoding: [chunked]
      vary: [Accept-Encoding]
    status: {code: 200, message: OK}
- request:
    body: null
    headers:
      Accept: ['*/*']
      Accept-Encoding: ['gzip, deflate']
      Connection: [close]
      User-Agent: [python-requests/2.18.4]
      X-ecommplatform-Access-Token: [xyz]
    method: GET
    uri: https://ACME-test-store.myecommplatform.com/admin/api/2019-10/puppies.json?fields=id&limit=250&since_id=0
  response:
    body: {string: !!python/unicode '{"puppies":[]}'}
    headers:... etc

# Configuration

Almost everything can be customised - you can write your own request matchers, your own persisters and filter functions.

For instance, you might want to skip saving sensitive data, and you might not be interested in all the headers that the response contains.

import vcr
 
@pytest.fixture(scope='module')
def vcr_config():
    return {
        # Replace the X-ecommplatform-Access-Token in request headers
        'filter_headers': [('X-ecommplatform-Access-Token', 'xyz')],
        # Decompress the response body so it's human-readable
        'decode_compressed_response': True,
    }


def filter_response():
    def before_record_response(response):
        # Filter out headers we are not interested in
        for key in response['headers'].keys():
            if key.startswith('x-'):
                response['headers'].pop(key)
        return response
    return before_record_response


def partial_uri_matcher(r1, r2):
    """
    Partial matcher which checks the url and query params to check if the
    request to be made matches any recorded response.
    """
    return all([
        r1.method == r2.method,
        r1.uri.split('api')[1] == r2.uri.split('api')[1],
    ])


@pytest.fixture(scope='module')
def vcr(vcr):
    vcr.register_matcher('partial_uri_matcher', partial_uri_matcher)
    vcr.match_on = ['partial_uri_matcher']
    vcr.before_record_response = filter_response()
    return vcr

# Benefits

  • Mocks based on real responses
  • Easy to do a regression test against a new API version: just delete the cassettes and re-run the tests
  • You can write your own persister to only save information you care about

# Drawbacks

  • One more dependency
  • Response is not clearly visible to the reader: cassettes are saved physically far away from the code
  • Accidentally committing secrets is higher: make sure to filter the response before recording!

# Thank you!

Written on December 23, 2019

If you notice anything wrong with this post (factual error, rude tone, bad grammar, typo, etc.), and you feel like giving feedback, please do so by contacting me at samubalogh@gmail.com. Thank you!