LaunchKit

Testing OmniAuth in Rails, without touching the provider

September 10, 2026

Almost every published guide to OmniAuth testing is out of date, and in the same two ways: it assumes Devise, and it predates OmniAuth 2. Snippets that assign request.env["omniauth.auth"] come from controller specs Rails no longer encourages, and snippets that get "/auth/google_oauth2" hit a request phase that has been POST-only since 2021. What follows is the current shape, for a plain Rails app with no authentication gem. If you have not wired a provider yet, adding Google sign-in from scratch is the page that builds what this one tests.

What OmniAuth test mode replaces

One line turns the whole handshake off:

OmniAuth.config.test_mode = true

With it set, a strategy stops talking to the provider. A request to the callback URL no longer exchanges a code for a token; the strategy reads a canned response out of a registry and puts it straight into request.env["omniauth.auth"], then hands control to your callback route. Your controller sees exactly the environment it would see in production, and nothing leaves the machine.

That is the important property. You are not stubbing your own code, which would leave the wiring between the strategy and your controller untested. You are replacing the provider, and everything on your side of the boundary still runs.

The mock is an AuthHash, not a double

The canned response goes into the OmniAuth mock_auth registry, keyed by strategy name, and it has to be an OmniAuth::AuthHash:

OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
  provider: "google_oauth2",
  uid: "google-123",
  info: { email: "jane@example.com" }
)

An AuthHash supports the nested method access your controller uses, so auth.info.email works the way it does in production. A plain Hash or an RSpec double does not, and the failure surfaces deep in your controller rather than at the line that set the mock.

Keep the mock minimal. Copying a full provider payload out of a gist means your spec asserts against thirty fields you never read, and it rots the first time the provider changes any of them.

Why the callback spec is a plain get

With the mock in place, the spec is unremarkable, and that is the point:

it "creates the user" do
  expect { get "/auth/google_oauth2/callback" }.to change(User, :count).by(1)
end

No env_config assignment, no request.env manipulation, no Devise mapping. Those appear in older guides because they were written for controller specs, where the request object is reachable and the middleware stack is not. A request spec runs the real middleware stack, so the strategy itself gets to do the substitution, which is both simpler and closer to production.

Resetting the mock between examples

OmniAuth.config.mock_auth is global, and RSpec does not reset it for you. An example that sets a mock leaves it in place for every example that follows, so a suite grows an ordering dependency that only shows up when someone runs it with a different seed.

RSpec.configure do |config|
  config.before { OmniAuth.config.mock_auth[:google_oauth2] = nil }
end

Setting it back to nil before every example makes each one declare its own provider response or have none. It costs one line and removes an entire class of flake, which is a better trade than remembering to clean up in each spec that opts in.

Covering the failure path with a Symbol

The path most suites never test is the one where the user presses "Cancel". OmniAuth covers it with a type switch: assign a Symbol instead of an AuthHash and the strategy calls fail! with it rather than proceeding, sending the request to the failure endpoint.

OmniAuth.config.mock_auth[:google_oauth2] = :invalid_credentials

Your failure route then runs for real, which is worth asserting on, because it is the branch that decides whether a rejected sign-in lands on a sensible page or on an unhandled error:

it "redirects to the sign-in page" do
  get "/auth/failure", params: { message: "access_denied" }
  expect(response).to redirect_to(new_session_path)
end

The request phase is a POST, and old snippets are not

OmniAuth 2 made the request phase reject GET, and in Rails the omniauth-rails_csrf_protection gem additionally requires a valid CSRF token on it. Both exist to stop login CSRF, where an attacker triggers a sign-in in a victim's browser to attach their own provider account to the victim's session.

OmniAuth.config.allowed_request_methods = [ :post ]

The consequence reaches your views, not just your specs: a sign-in button has to be a form, not a link, which is why button_to appears wherever a provider is offered. It also means a spec that starts at /auth/google_oauth2 with a GET is testing nothing but the rejection. Running several providers at once covers the request-phase trap in more detail.

A provider with no credentials has no route

The subtle one. A provider is only mounted inside OmniAuth::Builder when you mount it, and mounting usually depends on credentials being present. On a fresh clone with an empty credentials file, every callback route disappears and every OAuth spec fails with a routing error rather than an assertion.

Test mode makes the fix cheap, because the credentials are never used:

providers = Rails.env.test? ? Auth::OauthProvider.all : Auth::OauthProvider.configured
providers.each { |oauth_provider| provider(*oauth_provider.omniauth_args) }

In the test environment every provider mounts, blank secrets and all, purely so the route exists. Everywhere else a provider appears only once it is configured, so an unconfigured button is never rendered and a half-wired provider never reaches a user.

What this codebase does around OmniAuth testing

The reset above lives in a support file rather than in each spec, so opting out is the deliberate act rather than opting in. The callback specs assert on outcomes the controller owns, a user created and a session opened, rather than on the shape of the provider payload. Account linking is covered separately, because its interesting cases turn on who is already signed in when the callback arrives, which the three linking cases work through. And the strategies are mounted from the same provider registry that renders the buttons, so a provider cannot be testable and unrenderable, or the reverse.

More on Social sign-in with OmniAuth

← All Social sign-in with OmniAuth articles