Why Are External Calls Bad?
When writing unit tests, the principle is to test each component in isolation from other components. If your tests fail because of concerns outside of your subject, then this is unintentional coupling. A call to an external network resource also (S3, Pusher, Facebook, Google, etc) introduces brittleness, and will slow down your test. Network requests are very expensive compared to memory and local disk access. These quickly add up and cause a slow running test suite.
VCR
Reference a Ruby gem in the testing Gemfile group of your Rails project for VCR. It is responsible for recording request/response traffic (complete with headers, params, etc) and serializing it as a testing fixture. This fixture can then be used by the test instead of making an external network call.
VCR documentation at: https://relishapp.com/vcr/vcr/docs
Webmock
External network calls via a number of avenues are disabled in testing via the Webmock gem. These methods include Net:HTTP
, Curl::Easy
, etc. If you can think it up, its probably blocked. This is intentional for the reasons listed earlier. Exceptions should not be made in tests.
Webmock documentation at: https://github.com/bblimke/webmock
How to Write a Test Using VCR
Take for example an Image class. Pretend it is responsible (in part) for uploading a file to Amazon’s S3 service. This is hosted on AWS and is an external network call. Currently, creating an image in a test will fail with an exception similar to the following:
it 'uploads an image' do FactoryGirl.create(:image) # this fails end
VCR::Errors::UnhandledHTTPRequestError: ================================================================================ An HTTP request has been made that VCR does not know how to handle: PUT https://bwtesting.s3.amazonaws.com/uploads/image/file/1/logo.png
The error explains that there is currently no cassette to play. We can create a cassette to be used going forward by temporarily enabling external network requests:
# spec/vcr_helper.rb require 'vcr' VCR.configure do |config| config.cassette_library_dir = "spec/fixtures" config.hook_into :webmock config.allow_http_connections_when_no_cassette = true config.configure_rspec_metadata! end
Uncomment the line
config.allow_http_connections_when_no_cassette = true
Now instruct your test to use VCR by adding an Rspec metadata tag of :vcr:
it 'uploads an image', :vcr do FactoryGirl.create(:image) end
Now you can run your test again with a passing result:
1 example, 0 failures, 1 passed
You will also note a new file has been created under spec/fixtures
. The specific path and file name are based on the location of your test. (Note if you rename your test, be sure to rename the fixture accordingly). Look at the fixture and you will see something like:
--- http_interactions: ... - request: method: put uri: https://bwtesting.s3.amazonaws.com/uploads/image/file/1/logo.png ...
One fixture can contain multiple network call representations.
If we comment back out in spec/vcr_helper.rb
we should now be able to run our spec without a network connection:
# spec/vcr_helper.rb require 'vcr' VCR.configure do |config| config.cassette_library_dir = "spec/fixtures" config.hook_into :webmock # config.allow_http_connections_when_no_cassette = true config.configure_rspec_metadata! end
Matching a Cassette
How does VCR know which cassette recording to use when mocking an external network request? The default behavior is to match on method (GET
, POST
, PUT
, etc) and the URI (the full URL of the resource)
You might have guessed that this can cause an issue with RESTful resources, or anything with a unique identifier in the URL. Take this example:
it 'uploads an image', :vcr do FactoryGirl.create(:image) FactoryGirl.create(:image) end
We create two images. This will now cause our test to fail with an error from VCR:
VCR::Errors::UnhandledHTTPRequestError: ================================================================================ An HTTP request has been made that VCR does not know how to handle: PUT https://bwtesting.s3.amazonaws.com/uploads/image/file/2/logo.png VCR is currently using the following cassette: - spec/fixtures/Image/uploads_an_image.yml - :record => :once - :match_requests_on => [:method, :uri]
This failed because the URI we previously recorded was for
https://bwtesting.s3.amazonaws.com/uploads/image/file/1/logo.png
and now we have the URI
https://bwtesting.s3.amazonaws.com/uploads/image/file/2/logo.png
Changing Matching behaviors
VCR fortunately provides flexibility in how to match an HTTP request. As part of the metadata, you can specify what to match on. For our test, we don’t really care about the specific URL, just that we simulate a good response from Amazon. This means its sufficient to match on method and host. In your test you can specify by doing:
it 'uploads an image' do VCR.use_cassette('spec/fixtures/Image/uploads_an_image', match_requests_on: [:method, :host]) do FactoryGirl.create(:image) FactoryGirl.create(:image) end end
Note that :vcr
is gone from our Rspec metadata. We have wrapped the code responsible for issuing the external network call in an explicit VCR block that allows us to match on different criteria of the HTTP request. This test should now pass.
Custom Matching
Maybe the built in match_requests_on attributes aren’t fine grained enough for your test. In that case, you can write your own matcher. See the example below for how to match on part of the path of a URI:
it 'uploads an image' do VCR.use_cassette('spec/fixtures/Image/uploads_an_image', match_requests_on: [:method, :host, method(:s3_matcher)]) do FactoryGirl.create(:image) FactoryGirl.create(:image) end end def s3_matcher(request_1, request_2) !!URI(request_1.uri).path[/\/uploads\/image\/file\/\d+/] end
I’ve added a new method into the array of values passed to
match_requests_on
This method takes two arguments (it iterates over each request in the fixture and compares it to this specific request until it finds a match). The return value of this method must be a boolean. If its true, then the match is made against one of the requests. If the match is false, it tries the next HTTP request until it runs out of options, and fails.