In development, soon or later we need to do some integration with a third-party service and usually this means perform http requests.
As a good developer, we'll add specs in our codebase to make sure that everything is well tested.
In order to keep our test suite faster and consistent, we need to mock our http requests. A simple and good way to achieve this is using the Webmock gem which allow easily mock the http responses for your requests.
# spec/webmock_spec.rb
stub_request(:get, 'https://example.com/service').
to_return(status: 200 body: '{"a": "abc"}')
Example of mock using Webmock
And this works well, but if we need perform many requests to accomplish a task or business logic, just mocking become complicated and hard to maintain.
Example: Lets imagine that we need import some products in our system, but these products are separeted in different categories and to get the products we need get the all the categories, iterate over them to retrieve the products:
let(:categories_endpoint) { "https://example.com/categories" }
let(:categories_response) do
[{id: 'category-one-id'}, {id: 'category-two-id', name: ''}].to_json
end
let(:first_category_endpoint) { "#{categories_endpoint}/category-one-id" }
let(:first_category_response) do
{
products_links: [
{link: 'https://example.com/categories/categor-one-id/product-one'}
]
}.to_json
end
let(:second_category_endpoint) { "#{categories_endpoint}/category-two-id" }
let(:second_category_response) do
{
products_links: [
{link: 'https://example.com/categories/categor-two-id/other-product'}
]
}.to_json
end
let(:first_product_endpoint) { 'https://example.com/categories/categor-one-id/product-one' }
let(:first_product_response) { read_fixture('fixtures/products/first_product.json') }
let(:second_product_endpoint) { 'https://example.com/categories/categor-two-id/other-product' }
let(:second_product_response) { read_fixture('fixtures/products/second_product.json') }
before do
stub_request(:get, categories_endpoint).to_return(status: 200, body: categories_response)
stub_request(:get, first_category_endpoint).to_return(status: 200, body: first_category_response)
stub_request(:get, second_category_endpoint).to_return(status: 200, body: second_category_response)
stub_request(:get, first_product_endpoint).to_return(status: 200, body: first_product_response)
stub_request(:get, second_product_endpoint).to_return(status: 200, body: second_product_response)
end
A possible mock for the example
As we can see the configurations to mock is huge, and if we need add a thirdy category, these configs will increase even more, becoming hard to read and maintain.
Another problem is that we are spending a time creating that could be use in other places in our application.
A way to avoid this is using the VCR gem.
The VCR gem
The VCR is a gem that record http interactions in our test suite and allow replay these interactions in future runs.
VCR usage
The VCR usage is pretty simple, as follows:
# Configure it
VCR.configure do |config|
config.cassette_library_dir = "fixtures/vcr_cassettes"
config.hook_into :webmock
end
# Uses it
it 'does something' do
VCR.use_cassette('my_cassete') do
expect(do_request.body).to eql({success: true}.to_json)
end
end
The configuration is simple but the usage is a bit annoing, requiring us wrap all the HTTP interactions in blocks, its also break the TDD "configure, execute and assert" pattern.
VCR with RSpec
The VCR provides an integration with RSpec that uses the RSpec's metadata to configure itself. The configuration required to use this feature is here. But the idea of this article is have fine-grained control over VCR, so we'll not use it.
Fine-grained control of VCR with RSpec
Let's configure the VCR with RSpec to give to us a fine-grained control for us. The configuration follow these steps:
- VCR regular configuration
- Configure VCR to don't run on specs which its not required
- Configure the VCR using RSpec
shared_contexts
VCR regular configuration
Just the regular configuration, as follows the VCR docs.
require 'vcr'
VCR.configure do |c|
c.cassette_library_dir = 'spec/vcr_cassettes'
c.hook_into :webmock
end
Disable VCR in specs which its not required
This is not exactly required, but its a good practice, also will make our tests fail if they perform some HTTP requests or use regular mocks without problems.
The VCR has an method turned_off
that accepts a block of code which to be executed without the VCR. So to disable the VCR on specs which its not required, we'll use the RSpec hook around
:
# specs/spec_helper.rb
RSpec.configure do |config|
config.around do |example|
# Just disable the VCR, the configuration for its usage
# will be done in a shared_context
if example.metadata[:vcr]
example.run
else
VCR.turned_off { example.run }
end
end
end
Configure VCR's shared_context
The RSpec's shared_context
will allow us enable the VCR only when we need:
shared_context 'with vcr', vcr: true do
around do |example|
VCR.turn_on!
VCR.use_cassette(cassette_name) do
example.run
end
VCR.turn_off!
end
end
With this shared_context
we can use it as follow and the http will be recorded:
describe 'using vcr', vcr: true do
# Configure the cassete name
let(:cassete_name) { 'path/to/the/interaction' }
it 'record the http interaction' do
expect(do_request.body).to eql({ success: true }.to_json)
end
it 'reuse the same cassete here' do
expect(do_request.headers).to include('x-custom-header' => 'abc')
end
end
Improving shared_context
The VCR use_cassete
method accepts many other options, like the record_mode
for example. Using the shared_context
and let
allow us configure the VCR to record new interactions in development but raise an error on CI, for example:
shared_context 'with vcr', vcr: true do
# Disable new records on CI. Most of the CI providers
# configure environment variable called CI.
let(:cassette_record) { ENV['CI'] ? :none : :new_episodes }
around do |example|
VCR.turn_on!
VCR.use_cassette(cassette_name, { record: cassette_record }) do
example.run
end
VCR.turn_off!
end
end
Creating specific shared_contexts
It is possible create specific shared_context
that configure the VCR for specific cases. For example, imagine that you need not ignore the headers for some specific requests.
shared_context 'with vcr matching headers', vcr_matching_headers: true do
around do |example|
VCR.turn_on!
VCR.use_cassette(cassette_name, { match_requests_on: [:method, :uri, :headers]}) do
example.run
end
VCR.turn_off!
end
end
Conclusion
There are more options on VCR that we could add, but the examples gives the idea about how to control the VCR in our test suite.
Top comments (0)