DEV Community

Cover image for Testing external APIs with Rspec and WebMock
Ana Nunes da Silva
Ana Nunes da Silva

Posted on • Edited on • Originally published at ananunesdasilva.com

Testing external APIs with Rspec and WebMock

Testing code you don't own can be a bit trickier than writing tests for code you own. If you're interacting with 3rd party APIs, you don't actually want to make real calls every time you run a test.

Reasons why you should not make real requests to external APIs from your tests:

  • it can make your tests slow
  • you're potentially creating, editing, or deleting real data with your tests
  • you might exhaust the API limit
  • if you're paying for requests, you're just wasting money
  • ...

Luckily we can use mocks to simulate the HTTP requests and their responses, instead of using real data.

I've recently written a simple script that makes a POST request to my external newsletter service provider every time a visitor subscribes to my newsletter. Here's the code:

module Services class NewSubscriber BASE_URI = "https://api.buttondown.email/v1/subscribers".freeze def initialize(email:, referrer_url:, notes: '', tags: [], metadata: {}) @email = email @referrer_url = referrer_url @notes = notes @tags = tags @metadata = metadata end def register! uri = URI.parse(BASE_URI) request = Net::HTTP::Post.new(uri.request_uri, headers) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.request(request, payload.to_json) end private def payload { "email" => @email, "metadata" => @metadata, "notes" => @notes, "referrer_url" => @referrer_url, "tags" => @tags } end def headers { 'Content-Type' => 'application/json', 'Authorization' => "Token #{Rails.application.credentials.dig(:buttondown, :api_key)}" } end end end 
Enter fullscreen mode Exit fullscreen mode

The first approach could be to mock the ::Net::HTTP ruby library that I'm using to make the request:

describe Services::NewSubscriber do let(:email) { 'user@example.com' } let(:referrer_url) { 'www.blog.com' } let(:tags) { ['blog'] } let(:options) { { tags: tags, notes: '', metadata: {} } } let(:payload) do { email: email, metadata: {}, notes: "", referrer_url: referrer_url, tags: tags } end describe '#register' do it 'sends a post request to the buttondown API' do response = Net::HTTPSuccess.new(1.0, '201', 'OK') expect_any_instance_of(Net::HTTP) .to receive(:request) .with(an_instance_of(Net::HTTP::Post), payload.to_json) .and_return(response) described_class.new(email: email, referrer_url: referrer_url, **options).register! expect(response.code).to eq("201") end end 
Enter fullscreen mode Exit fullscreen mode

This test passes but there are some caveats to this approach:

  • I'm too tied to the implementation. If one day I decide to use Faraday or HTTParty as my HTTP clients instead of Net::HTTP, this test will fail.
  • It's easy to break the code without making this test fail. For instance, this test is indifferent to the arguments that I'm sending to the Net::HTTP::Post and Net::HTTP instances.

Testing behavior with WebMock

WebMock is a library that helps you stub and set expectations on HTTP requests.

You can find the setup instructions and examples on how to use WebMock, in their github documentation. WebMock will prevent any external HTTP requests from your application so make sure you add this gem under the test group of your Gemfile.

With Webmock I can stub requests based on method, uri, body, and headers. I can also customize the returned response to help me set some expectations base on it.

require 'webmock/rspec' describe Services::NewSubscriber do let(:email) { 'user@example.com' } let(:referrer_url) { 'www.blog.com' } let(:tags) { ['blog'] } let(:options) { { tags: tags, notes: '', metadata: {} } } let(:payload) do { email: email, metadata: {}, notes: '', referrer_url: referrer_url, tags: tags } end let(:base_uri) { "https://api.buttondown.email/v1/subscribers" } let(:headers) do { 'Content-Type' => 'application/json', 'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'User-Agent'=>'Ruby' } end let(:response_body) { File.open('./spec/fixtures/buttondown_response_body.json') } describe '#register!' do it 'sends a post request to the buttondown API' do stub_request(:post, base_uri) .with(body: payload.to_json, headers: headers) .to_return( body: response_body, status: 201, headers: headers) response = described_class.new(email: email, referrer_url: referrer_url, **options).register! expect(response.code).to eq('201') end end end 
Enter fullscreen mode Exit fullscreen mode

Now I can pick another library to implement this script and this test should still pass. But if the script makes a different call from the one registered by the stub, it will fail. This is what I want to test - behavior, not implementation. So if, for instance, I run the script passing a different subscriber email from the one passed to the stub I'll get this failure message:

1) Services::NewSubscriber#register! sends a post request to the buttondown API Failure/Error: http.request(request, payload.to_json) WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. Unregistered request: POST https://api.buttondown.email/v1/subscribers with body '{"email":"ana@test.com","metadata":{},"notes":"","ref errer_url":"www.blog.com","tags":["blog"]}' with headers {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'Token 26ac0f19-24f3-4ac c-b993-8b3d0286e6a0', 'Content-Type'=>'application/json', 'User-Agent'=>'Ruby'} You can stub this request with the following snippet: stub_request(:post, "https://api.buttondown.email/v1/subscribers"). with( body: "{\"email\":\"ana@test.com\",\"metadata\":{},\"notes\":\"\",\"referrer_url\":\"www.blog.com\",\"tags\":[\"blog\"]}", headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'Token 26ac0f19-24f3-4acc-b993-8b3d0286e6a0', 'Content-Type'=>'application/json', 'User-Agent'=>'Ruby' }). to_return(status: 200, body: "", headers: {}) registered request stubs: stub_request(:post, "https://api.buttondown.email/v1/subscribers"). with( body: "{\"email\":\"user@example.com\",\"metadata\":{},\"notes\":\"\",\"referrer_url\":\"www.blog.com\",\"tags\":[\"blog\"]}", headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Type'=>'application/json', 'User-Agent'=>'Ruby' }) Body diff: [["~", "email", "ana@test.com", "user@example.com"]] 
Enter fullscreen mode Exit fullscreen mode

There's not much more to it than this. VCR is another tool that also stubs API calls but works a little differently by actually making a first real request that will be saved in a file for future use. For simple API calls, WebMock does the trick for me!

Top comments (1)

Collapse
 
storrence88 profile image
Steven Torrence

Awesome write-up! I've used VCR in the past for all of my mock requests. You've inspired me to take a look at Webmock! Thanks!