David Runger : Blog

Mocking external requests in Rails feature tests

essentially, WebMock for the (test) browser

January 30, 2023 · David Runger | Edit this article on GitHub

Without any introduction, allow me to share some code that I want to talk about, which exists within a Rails feature test:

around do |spec|
  page.driver.browser.intercept do |request, &continue|
    continue.call(request) do |response|
      if request.url.start_with?('https://accounts.google.com/o/oauth2/auth?')
        response.code = 200
        response.body = 'This is Google OAuth.'
      end
    end
  end

  spec.run
  # ...
end

The purpose of that code is to mock Google’s response to a request made by a Chrome browser that’s being driven through an OAuth authorization flow by Capybara in an automated Rails test suite. I want to share it here because, when I was searching how to mock a browser request made within a feature test, my Googling failed to bring up many relevant or helpful code examples.

If that’s all that you’re looking for, then maybe copy-paste that code and stop reading here. 🙂 One additional note, though: for this to work, you’ll need to add the selenium-devtools gem to the test group of your Gemfile, if it’s not there already.

If you’re interested in some additional background context, though, read on.

Why, though? Some background.

The code above comes from a spec seen here. That spec verifies the OAuth flow for logging in to my website using Google OAuth. That test was added in this commit.

Content Security Policy (CSP)

I had previously introduced a bug when modifying my app’s Content Security Policy (CSP). Setting up an effective CSP can be a little annoying, and there’s a risk of introducing bugs when doing it (as happened to me), but the upside is that a well-crafted CSP can provide a significant amount of protection against various potential security vulnerabilities in a web app.

OAuth

When initiating an OAuth authorization flow (e.g. by clicking a “Sign in with Google” button), a user’s browser will make a request to my app’s server, and my app’s server will respond with a redirect to a Google URL. However, in some browsers (as discussed here), if the application has a form-action CSP directive that doesn’t specifically authorize a redirect to the Google domain in question, the browser will refuse to follow the redirect, thus halting/breaking the OAuth flow and preventing the user from logging in.

I had inadvertently broken my app in that way when setting up a CSP for my application. After realizing that this bug was happening and figuring out how to fix it, I also wanted to write a test to reproduce the bug and ensure that particular bug would never be reintroduced.

Tests should be able to run (and pass) without an Internet connection

A relatively straightforward way to write such a regression test for the aforementioned bug would be to write a feature test that visits my app’s login page (hosted by a test server), clicks the “Sign in with Google” button, and then checks that the browser follows the redirect to a Google login page, and that the page has some content like “Google will share your name, email address, language preference, and profile picture with davidrunger.com”.

However, that approach relies on the test browser making a real, live external request over the Internet to a Google web server in order for the test to pass. This would violate one of my guiding principles of testing: tests should be able to run (and pass) without an Internet connection. There are a few reasons for this, the biggest of which are probably test speed and test reliability.

WebMock for the test browser

When writing unit tests in Ruby, external HTTP requests that are made by the Ruby application can be mocked using the most excellent webmock gem. WebMock can mock requests that are made from the Ruby app in question to an external server. However, it doesn’t allow us to mock a request from a Chrome browser that’s being driven in a feature test to an external server (in this example, a Google server for OAuth).

What we basically need is the equivalent of WebMock for a feature test. And that’s just what the code at the top of this article does.

Below is that code within the larger context of the regression test in question. I’ll make some additional comments about this test beneath the code.

RSpec.describe 'Logging in as a User via Google auth' do
  context 'when OmniAuth test mode is disabled' do
    around do |spec|
      original_omni_auth_test_mode = OmniAuth.config.test_mode
      OmniAuth.config.test_mode = false

      spec.run

      OmniAuth.config.test_mode = original_omni_auth_test_mode
    end

    context 'when Google responds with "This is Google OAuth."' do
      let(:mocked_google_response) { 'This is Google OAuth.' }

      around do |spec|
        page.driver.browser.intercept do |request, &continue|
          continue.call(request) do |response|
            if request.url.start_with?(
              'https://accounts.google.com/o/oauth2/auth?',
            )
              response.code = 200
              response.body = mocked_google_response
            end
          end
        end

        spec.run

        # try to do some cleanup (though I'm not sure how useful this is)
        page.driver.browser.devtools.callbacks.clear
        page.driver.browser.devtools.fetch.disable
      end

      it "renders Google's response" do
        visit(login_path)
        expect(page).to have_css('button.google-login')

        click_button(class: 'google-login')

        # The point of all of this: verify that the browser
        # indeed followed the redirect to Google.
        expect(page).to have_text(mocked_google_response)
      end
    end
  end
end

First, within an RSpec around block, we disable OmniAuth test mode in this test. This is necessary because, when the OmniAuth test mode is enabled, OmniAuth will not actually redirect the browser to an external OAuth server. So, in order to test that the browser does follow a redirect to an external Google OAuth server, we need to temporarily ensure that the OmniAuth test mode is disabled. We then restore OmniAuth.config.test_mode to its original value (presumably true) after the test has completed.

Next is the code that mocks the request to accounts.google.com, providing a specified response code (200) and content (“This is Google OAuth.”). I wrote some code at the end of that around block to try to do some cleanup to restore the test browser etc to its original state (though I’m not sure if it really helps anything).

Finally, is the test example itself, which visits the login page, clicks on the Google login button, and verifies, by checking for the mocked_google_response content, that the browser was indeed willing (even given my application’s Content Security Policy) to follow a redirect to the external Google URL.