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!