Requests to external services during test runs can cause several issues:
- Tests failing intermittently due to connectivity issues.
- Dramatically slower test suites.
- Hitting API rate limits on 3rd party sites (e.g. Twitter).
- Service may not exist yet (only documentation for it).
- Service doesn’t have a sandbox or staging server.
When integrating with external services we want to make sure our test suite isn’t hitting any 3rd party services. Our tests should run in isolation.
Disable all remote connections
We’ll use Webmock, a gem which helps to stub out external HTTP requests. In this example we’ll search the GitHub API for contributors to the FactoryBot repository.
First, let’s make sure our test suite can’t make external requests by disabling
them in our spec_helper.rb
:
# spec/spec_helper.rb
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
Now let’s verify that any external requests will raise an exception and break the build:
# spec/features/external_request_spec.rb
require 'spec_helper'
feature 'External request' do
it 'queries FactoryBot contributors on GitHub' do
uri = URI('https://api.github.com/repos/thoughtbot/factory_bot/contributors')
response = Net::HTTP.get(uri)
expect(response).to be_an_instance_of(String)
end
end
As expected we now see errors when external requests are made:
$ rspec spec/features/external_request_spec.rb
F
Failures:
1) External request queries FactoryBot contributors on GitHub
Failure/Error: response = Net::HTTP.get(uri)
WebMock::NetConnectNotAllowedError:
Real HTTP connections are disabled.
Unregistered request: GET https://api.github.com/repos/thoughtbot/factory_bot/contributors
with headers {
'Accept'=>'*/*',
'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Host'=>'api.github.com',
'User-Agent'=>'Ruby'
}
You can stub this request with the following snippet:
stub_request(:get, "https://api.github.com/repos/thoughtbot/factory_bot/contributors").
with(:headers => {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Host'=>'api.github.com', 'User-Agent'=>'Ruby'}).
to_return(:status => 200, :body => "", :headers => {})
============================================================
# ./spec/features/external_request_spec.rb:8:in `block (2 levels) in <top (required)>'
Finished in 0.00499 seconds
1 example, 1 failure
We can fix this by stubbing any requests to api.github.com
with Webmock, and
returning pre-defined content.
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
stub_request(:get, /api.github.com/).
with(headers: {'Accept'=>'*/*', 'User-Agent'=>'Ruby'}).
to_return(status: 200, body: "stubbed response", headers: {})
end
end
Run the test again and now it will pass.
$ rspec spec/features/external_request_spec.rb
.
Finished in 0.01116 seconds
1 example, 0 failures
VCR
Another approach for preventing external requests is to record a live interaction and ‘replay’ it back during tests. The VCR gem has a concept of cassettes which will record your test suites outgoing HTTP requests and then replay them for future test runs.
Considerations when using VCR:
- Communication on how cassettes are shared with other developers.
- Needs the external service to be available for first test run.
- Difficult to simulate errors.
We’ll go a different route and create a fake version of the GitHub service.
Create a Fake (Hello Sinatra!)
When your application depends heavily on a third party service, consider building a fake service inside your application with Sinatra. This will let us run full integration tests in total isolation, and control the responses to our test suite.
First we use Webmock to route all requests to our Sinatra application, FakeGitHub
.
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
stub_request(:any, /api.github.com/).to_rack(FakeGitHub)
end
end
Next we’ll create the FakeGitHub
application.
# spec/support/fake_github.rb
require 'sinatra/base'
class FakeGitHub < Sinatra::Base
get '/repos/:organization/:project/contributors' do
json_response 200, 'contributors.json'
end
private
def json_response(response_code, file_name)
content_type :json
status response_code
File.open(File.dirname(__FILE__) + '/fixtures/' + file_name, 'rb').read
end
end
Download a sample JSON response and store it in a local file.
# spec/support/fixtures/contributors.json
[
{
"login": "joshuaclayton",
"id": 1574,
"avatar_url": "https://2.gravatar.com/avatar/786f05409ca8d18bae8d59200156272c?d=https%3A%2F%2Fidenticons.github.com%2F0d4f4805c36dc6853edfa4c7e1638b48.png",
"gravatar_id": "786f05409ca8d18bae8d59200156272c",
"url": "https://api.github.com/users/joshuaclayton",
"html_url": "https://github.com/joshuaclayton",
"followers_url": "https://api.github.com/users/joshuaclayton/followers",
"following_url": "https://api.github.com/users/joshuaclayton/following{/other_user}",
"gists_url": "https://api.github.com/users/joshuaclayton/gists{/gist_id}",
"starred_url": "https://api.github.com/users/joshuaclayton/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/joshuaclayton/subscriptions",
"organizations_url": "https://api.github.com/users/joshuaclayton/orgs",
"repos_url": "https://api.github.com/users/joshuaclayton/repos",
"events_url": "https://api.github.com/users/joshuaclayton/events{/privacy}",
"received_events_url": "https://api.github.com/users/joshuaclayton/received_events",
"type": "User",
"site_admin": false,
"contributions": 377
}
]
Update the test, and verify the expected stub response is being returned.
require 'spec_helper'
feature 'External request' do
it 'queries FactoryBot contributors on GitHub' do
uri = URI('https://api.github.com/repos/thoughtbot/factory_bot/contributors')
response = JSON.load(Net::HTTP.get(uri))
expect(response.first['login']).to eq 'joshuaclayton'
end
end
Run the specs.
$ rspec spec/features/external_request_spec.rb
.
Finished in 0.04713 seconds
1 example, 0 failures
Voilà, all green! This now allows us to run a full integration test without ever having to make an external connection.
A few things to consider when creating a fake:
- A fake version of a service can lead to additional maintenance overhead.
- Your fake could get out of sync with the external endpoint.
What’s next
If you found this useful, you might also enjoy:
Disclaimer:
Looking for FactoryGirl? The library was renamed in 2017. Project name history can be found here.