Auth0 login looks simple in the browser. You click Sign in, get redirected, and come back logged in. While testing, the same flow is a trap. You do not want every `bin/rails test` run to depend on:

- Auth0 being up
- Test users existing in a tenant, or
- Headless browser grinding through a multi-step redirect chain

But you still need confidence that your app does the right thing when Auth0 sends a user back:

- The session should exist
- Protected pages should work, and
- Sign-out should clear the session

This post walks through how to test that flow with Minitest and OmniAuth test mode, without relying on Auth0 in CI.

If you are using Rails 8's built-in `bin/rails generate authentication` instead of Auth0, see [How to Test Controllers Protected by Rails 8 Authentication with Minitest](/blog/testing-rails-8-authentication-minitest/). If you are looking for a deeper dive into testing authentication, the [testing authentication](/guide/testing-authentication/) chapter in the Minitest Rails guide goes through the authentication integration in the app and teaches you a way to test all edge cases with Minitest. This post is for apps that delegate login to Auth0, or to any OmniAuth provider wired the same way.

## Assumptions

This walkthrough assumes the following:

- You are using Minitest for writing tests.
- You have already integrated auth0 in your app and it includes:
  - [`omniauth-auth0`](https://github.com/auth0/omniauth-auth0?utm_source=minitestrails.com) gem (or plain `omniauth` + Auth0 strategy).
  - Auth0 callback route like `/auth/auth0/callback` and a failure route.
  - User identifier stored in the session after login (`session[:user_id]`, `Current.session`, etc.).
- You are using [Fixtures](https://api.rubyonrails.org/classes/ActionText/FixtureSet.html?utm_source=minitestrails.com) to load test data.

Adjust names to match your app. The patterns stay the same even when paths or session keys differ.

## Tested and working in

The code in this walkthrough was tested and working in:

- Ruby 4.0.2
- Rails 8.1.3
- Minitest 6.0.6
- selenium-webdriver 4.44.0
- omniauth-auth0 3.2.0

## Sample companion app

Every code block in this post comes from a runnable Rails app: [auth0-minitest-rails](https://github.com/minitestrails/auth0-minitest-rails?utm_source=minitestrails.com). Clone it, follow the README for local Auth0 setup, and diff your app when a test does not match.

The integration and system tests from this walkthrough are on the `test-auth0` branch in [PR #4](https://github.com/minitestrails/auth0-minitest-rails/pull/4?utm_source=minitestrails.com). Open that pull request when you want the full file list in one place instead of copying snippets by hand.

## What you are actually testing

When I first wired Auth0 into a Rails app, I kept wanting the test suite to prove "login works end to end." That usually means opening Auth0 in a browser, or stubbing ten redirects, or maintaining a test user in a real tenant. None of that belongs in every CI run.

It helps to split the problem in two:

1. Your Rails app

    What happens after Auth0 sends the user back (callback route, session or user record, redirects, and who can reach protected pages).

2. Auth0

    Their login screen, MFA, password reset, and issuing tokens.

Automated tests should cover the first bucket. Manual smoke tests, or Auth0's own tooling, can cover the second. Trying to automate the full redirect flow on every push is slow, flaky, and usually unnecessary.

Here is how different types of Rails tests map to Auth0 login, once you accept that split:

| Test type | Good for Auth0 login |
| --- | --- |
| Model / unit | Auth0 user lookup, validations |
| Integration | Callback POST/GET, session cookie, protected routes, sign-out |
| System | Optional: one happy-path click if you stub OmniAuth in the browser session |
| Real Auth0 redirect in CI | Avoid |

Integration tests are where most of the value lives. They hit your callback and session logic the way the rest of your app will, without leaving your app host. I try to avoid system tests because they are slow and flaky in nature, I write them strictly for smoke tests.

Now let's get on to testing.

## Turn on OmniAuth test mode in test

OmniAuth ships a test mode that lets you POST a fake auth hash to `/auth/:provider/callback` instead of visiting Auth0. That is the core trick for the rest of the post.

In `config/environments/test.rb`, turn it on for the test environment:

```ruby
# config/environments/test.rb
Rails.application.configure do
  # ...
  OmniAuth.config.test_mode = true
end
```

## Add Auth0 test helper

Next, add a small helper file under `test/support/` with two methods:

- `default_auth0_user` holds the reusable Alice profile that matches your `test/fixtures.user.yml`
- `load_omniauth_mock` writes that user's fake Auth0 response into OmniAuth's test config and `Rails.application.env_config["omniauth.auth"]` so callback tests behave like a real redirect landed.

Create `test/support/auth0_test_helper.rb` and add the following inside:

```ruby
# test/support/auth0_test_helper.rb
module Auth0TestHelper
  def default_auth0_user
    OmniAuth::AuthHash.new(
      provider: "auth0",
      uid: "auth0|test-user-1",
      info: {
        email: "alice@example.com",
        name: "Alice Example"
      },
      extra: {
        raw_info: {
          "sub" => "auth0|test-user-1",
          "email" => "alice@example.com",
          "name" => "Alice Example"
        }
      }
    )
  end

  def load_omniauth_mock(auth0_uid:, email: nil, name: nil)
    email ||= default_auth0_user.info["email"]
    name ||= default_auth0_user.info["name"]

    OmniAuth.config.mock_auth[:auth0] = OmniAuth::AuthHash.new(
      "provider" => "auth0",
      "uid" => auth0_uid,
      "info" => {
        "email" => email,
        "name" => name
      },
      :extra => {
        raw_info: {
          "sub" => auth0_uid,
          "email" => email,
          "name" => name
        }
      }
    )
    Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[
      :auth0
    ]
  end
end
```

Open `test/test_helper.rb`. If it does not already load support files, add this near the top (after `require "rails/test_help"`). Skip this line if your project already has it:

```ruby
# test/test_helper.rb
Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
```

That line finds every `.rb` file under `test/support/` and requires them in sorted order, so modules like `Auth0TestHelper` are defined before your tests run. This way you don't have to  add any other `require` for files inside the test support folder.

Just below `class TestCase`, include the helper:

```ruby
# test/test_helper.rb
module ActiveSupport
  class TestCase
    include Auth0TestHelper
  end
end
```

<%= render Shared::Tip.new(
  title: "Do not hit Auth0 in test",
  markdown: "Keep `AUTH0_DOMAIN`, client secrets, and real callback URLs out of the test path. Test mode short-circuits the OAuth request phase. Your tests prove your callback and session logic, not Auth0's uptime."
) %>

## User fixtures for Auth0

Fixtures are sample records Rails loads into the test database before each run. Login tests (coming up in next section) look up an existing user by `auth0_uid` when the callback fires, so you need a fixture row that matches what `default_auth0_user` and `load_omniauth_mock` send back.

You might be using Factory Bot instead; if so, adjust the shape of your test data, but keep the same `auth0_uid` values.

Open `test/fixtures/users.yml` and make sure each column matches the following:

```yaml
# test/fixtures/users.yml
alice:
  email: alice@example.com
  auth0_uid: auth0|test-user-1
  name: Alice Example
```

We will use these columns and rely on their values in the next section where we add Integration Tests so these need to be correct.

## Integration tests: login, signup, and failure

Picture this flow in your app:

- User clicks Sign in and is redirected to Auth0.
- User enters credentials in Auth0 and, on success, returns to your app.
- Your app hits the callback URL.
- Your app finds or creates a local user, then sets the session so later requests stay signed in.

That would amount to three edge cases in the test file:

- an existing user logs in
- a new user is created on first login, and
- a failed Auth0 attempt still lands somewhere sensible

Create a new integration test file for the Auth0 login from your project root with:

```bash
$ nano test/integration/auth0_login_integration_test.rb
```

Add the following inside it:

```ruby
# test/integration/auth0_login_integration_test.rb
require "test_helper"

class Auth0LoginIntegrationTest < ActionDispatch::IntegrationTest
  test "logs in the user" do
    user = users(:alice)
    load_omniauth_mock(
      auth0_uid: user.auth0_uid,
      email: user.email,
      name: user.name
    )

    assert_no_difference "User.count" do
      get "/auth/auth0/callback"
    end

    follow_redirect!
    assert_response :success
    assert_equal user.id, session[:user_id]
    assert_equal "auth0|test-user-1", session[:userinfo]["sub"]
  end

  test "creates a user and logs them in" do
    load_omniauth_mock(
      auth0_uid: "auth0|test-user-2",
      email: "bob@example.com",
      name: "Bob Example"
    )

    assert_difference "User.count", 1 do
      get "/auth/auth0/callback"
    end

    user = User.find_by(email: "bob@example.com")
    assert_equal "auth0|test-user-2", user.auth0_uid

    follow_redirect!
    assert_response :success
    assert_equal user.id, session[:user_id]
  end

  test "failure redirects with a message" do
    OmniAuth.config.mock_auth[:auth0] = :invalid_credentials

    get "/auth/auth0"
    follow_redirect! # OmniAuth failure redirect (redirected from Auth0)
    follow_redirect! # /auth/failure

    assert_response :success
    assert_match(/authentication failed/i, response.body)
  end
end
```

Run the test:

```bash
bin/rails test test/integration/auth0_login_integration_test.rb
```

You want 0 failures and 0 errors. If you see `ActionController::RoutingError`, your OmniAuth mount path differs from the example. Check `config/routes.rb` for how `auth/:provider` is wired in your app, this is what I have:

```ruby
# config/routes.rb
Rails.application.routes.draw do
  # ...
  get "/auth/auth0/callback" => "auth0#callback"
  get "/auth/failure" => "auth0#failure"
  get "/auth/logout" => "auth0#logout"
  # ...
end
```

What each test block is testing:

- The first test signs in `users(:alice)` from fixtures. No new row should appear.
- The second uses a mock for someone not in fixtures. Callback creates the user, then sets the session.
- The third sets an invalid mock and checks that failure still lands somewhere sensible in your UI.

## Testing protected routes

A **protected route** is a page your app only serves to signed-in users. The controller checks the session for signed-in user before it allows the access to these pages. If nobody is logged in, the app redirects the user to sign-in or sends the visitor back to the home page instead of showing the page.

A dashboard is a common example but it could also be any other pages like account settings, billing, or anything behind `before_action :authenticate_user!`. Guests should not see it. Someone who just got redirected back from Auth0 after successful login attempt should see the page.

The signed-in test repeats the same login steps from the callback test: `load_omniauth_mock`, `get "/auth/auth0/callback"`, and `follow_redirect!`. Create a new integration file for the dashboard test:

```bash
nano test/integration/dashboard_integration_test.rb
```

Add the following inside it:

```ruby
# test/integration/dashboard_integration_test.rb
require "test_helper"

class DashboardIntegrationTest < ActionDispatch::IntegrationTest
  test "signed-in user can access the dashboard" do
    user = users(:alice)
    load_omniauth_mock(
      auth0_uid: user.auth0_uid,
      email: user.email,
      name: user.name
    )
    get "/auth/auth0/callback"
    follow_redirect!

    get dashboard_path
    assert_response :success
  end

  test "guest cannot access the dashboard" do
    get dashboard_path
    assert_redirected_to root_path # or your sign-in page
  end
end
```

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: "Guest test is the one people normally skip. Auth0 or not, guest denial belongs near happy-path login. If you only test \"sign in works,\" you still do not know whether unauthenticated visitors are blocked from protected pages."
) %>

## Helper: sign in without repeating the callback

The login and dashboard tests both open with the same three lines: `load_omniauth_mock(...)`, `get "/auth/auth0/callback"`, and `follow_redirect!`. Every protected-route test you add will copy that block again.

Let's extract it into a helper method `sign_in_as` on the same `test/support/auth0_test_helper.rb` file so we can reuse it anywhere in the app that requires login:

```ruby
# test/support/auth0_test_helper.rb
module Auth0TestHelper
  def default_auth0_user
    # ... same as above
  end

  def load_omniauth_mock(auth0_uid:, email: nil, name: nil)
    # ... same as above
  end

  def sign_in_as(user: nil, email: nil)
    if user.nil? && email.nil?
      raise ArgumentError, "Pass user: or email:"
    end

    if user
      load_omniauth_mock(
        auth0_uid: user.auth0_uid,
        email: user.email,
        name: user.name
      )
    else
      load_omniauth_mock(
        auth0_uid: "auth0|#{email.parameterize}",
        email: email,
        name: email.split("@").first.titleize
      )
    end

    get "/auth/auth0/callback"
    follow_redirect!
  end
end
```

Because `Auth0TestHelper` is already included in `ActiveSupport::TestCase`, integration tests can call `sign_in_as` without extra setup. Update the dashboard_integration_test to use this new helper method, it should look like the following after the refactor:

```ruby
# test/integration/dashboard_integration_test.rb
require "test_helper"

class DashboardIntegrationTest < ActionDispatch::IntegrationTest
  test "signed-in user can access the dashboard" do
    sign_in_as(user: users(:alice))

    get dashboard_path
    assert_response :success
  end

  test "guest cannot access the dashboard" do
    # same as previous
  end
end
```

We can't use `sign_in_as` in Auth0LoginIntegrationTest because we are also testing User count difference after the login, but this method will be very handy for other protected routes and will increase reusability inside the app.

## System test (optional, one happy path)

System test is optional since we have already added thorough integration tests. But I still like to always make sure pages load correctly in the browser with Javascript running, so I write a small smoke test for each page.

System tests drive a real browser. They can answer "does the Sign in button exist and does the page update," but you should still stub OmniAuth instead of opening Auth0.

```ruby
# test/system/authentication_test.rb
require "application_system_test_case"

class AuthenticationTest < ApplicationSystemTestCase
  setup do
    load_omniauth_mock(auth0_uid: "auth0|test-user-1")
  end

  test "visitor signs in to the app" do
    visit root_path
    click_on "Sign in"

    assert_text "Signed in as alice@example.com" # or match any other text in your UI
  end
end
```

Run with:

```bash
bin/rails test test/system/authentication_test.rb
```

You should get 0 failures and 0 error.

One green system test is enough for a smoke check. The rest of your auth confidence should live in integration tests, which are faster and easier to debug.

## What to commit

After the tests we added above pass, commit a small slice:

```bash
bin/rails test test/integration/auth0_login_integration_test.rb
bin/rails test test/system/authentication_test.rb
git add test/ config/environments/test.rb
git commit -m "Add Auth0 login tests"
```

Small commits make it obvious which layer broke when someone changes the callback or session store later. You will thank yourself when a teammate refactors `SessionsController` and only one test file goes red.

## CI gotcha: `config/auth0.yml` must exist

Tests do not call Auth0, but Rails still boots your app and loads Auth0 config from a YAML file. If that file is gitignored (common for real credentials), CI fails before any test runs with the following error:

```
Could not load configuration. No such file - .../config/auth0.yml
```

Commit a placeholder file with fake values for the test environment. The sample app uses `config/auth0.yml.example`:

```yaml
# config/auth0.yml.example
test:
  auth0_domain: your-tenant.auth0.com
  auth0_client_id: your_client_id
  auth0_client_secret: your_client_secret
```

In GitHub Actions, copy it before `bin/rails test` and `bin/rails test:system`:

```yaml
# .github/workflows/ci.yml

# ...
# .....
# ...
jobs:
  test:
    runs-on: ubuntu-latest

    # ...
    # .....
    # ...
    steps:
      # ...
      - name: Set up Auth0 config
        run: cp config/auth0.yml.example config/auth0.yml

      - name: Run tests
        env:
          RAILS_ENV: test
        run: bin/rails db:test:prepare test
  system-test:
    runs-on: ubuntu-latest

    steps:
      # ...
      - name: Set up Auth0 config
        run: cp config/auth0.yml.example config/auth0.yml

      - name: Run System Tests
        env:
          RAILS_ENV: test
        run: bin/rails db:test:prepare test:system

      # ...
      # .....
      # ...
```

Placeholder values are fine. OmniAuth test mode never opens a real tenant; the file just has to be there so Rails can boot.

## Recap

We made Auth0 login testable in CI without ever opening Auth0 in a browser.

- Use OmniAuth test mode so tests stay inside your app.
- Test the callback in an integration test:
  - Existing fixture user logs in (no new row).
  - First-time login creates a user and sets the session.
  - Failed login redirects with a helpful message.
- Test one protected route (dashboard) for both signed-in and guest users.
- Test the login with smoke styled system test to ensure mock behaves correctly in real browser session as well.
- Keep live Auth0 for manual checks when you change tenant settings or the login UI.

## Where to go next

This post is one slice of a bigger topic: testing Rails apps with Minitest. The [Minitest Rails guide](/guide/) walks through that from your first test through models, system tests, mailers, jobs, and CI. You build one small app as you read, so examples stay connected instead of jumping between toy snippets.

If your app uses Rails' built-in authentication (email and password from `bin/rails generate authentication`) rather than Auth0, start with the [testing authentication](/guide/testing-authentication/) chapter when you get there in order. You do not need Auth0 or OmniAuth to follow the rest of the guide.
