I instantly think of Devise when someone asks me about authentication in Rails. I still use it in many of my Rails apps and think Devise is still a default authentication solution for many Rails developers. One of the many reasons Devise got a special place in Rails community is also because there was no built-in authentication solution in Rails for a very long time. Then Rails 8 happened!

Rails 8 shipped with many goodies and among them there was also a built-in authentication generator that you can get up and running by simply hitting `bin/rails generate authentication` in the command line. Once you have run the generator, you can add `before_action :require_authentication` to a controller and guests get redirected to sign in.

All of these implementation details are documented in the [Securing Rails Applications guide](https://guides.rubyonrails.org/security.html?utm_source=minitestrails.com#authentication). But what about testing?

That's where most people get stuck and I have seen the same set of questions showing up on forums and threads many times:

- How to test controllers protected by the new auth?
- How do I hit a protected action without filling the sign-in form on every test?
- How to sign in for system tests with Rails authentication generator?
- Why does my controller test get a redirect when I thought I was signed in?

I am writing this post to answer all the questions above, once and for all. In this post, you will learn about writing integration and system tests for the built-in authentication solution introduced in Rails 8.

If you use Auth0 or another OmniAuth provider instead of Rails' built-in auth, see [How to Test Auth0 Login in Rails with Minitest](/blog/testing-auth0-login-rails-minitest/). If you want the full guide chapter with ownership rules (authorization) included, see [testing authentication](/guide/testing-authentication/) and [authorization testing](/guide/authorization-testing/).

## Assumptions

This walkthrough assumes:

- Rails 8 with `bin/rails generate authentication` already run.
- A `User` model with `email_address` and `password_digest`, plus a `Session` model (created by the authentication generator).
- Rails app where guests may browse (`index`, `show`) but only signed-in users may create, edit, or destroy. We will use the Cookbook app from the free [Minitest Rails guide](/guide/introduction/#what-youll-use-the-recipes-app) for this.
- Minitest and fixtures (Rails default).

## Tested and working in

The code in this post is tested and working in:

- Ruby 4.0.5
- Rails 8.1.3
- Minitest 6.0.6
- selenium-webdriver 4.46.0

## Sample companion app

Every code block in this post comes from a runnable Rails app: [test-rails-default-auth-minitest](https://github.com/minitestrails/test-rails-default-auth-minitest?utm_source=minitestrails.com) based on the Cookbook app we are using in the Minitest Rails guide.

If you are new to testing auth, you can clone the app and follow along locally:

```bash
git clone git@github.com:minitestrails/test-rails-default-auth-minitest.git
cd test-rails-default-auth-minitest
git checkout test-authentication
bin/setup --skip-server
bin/rails test:all
```

You should see a green suite when running all tests. Work through each section below, run the matching test file, and diff your changes against the sample app when something does not match. The README has fixture passwords and which routes are guest-accessible.

If you already know your way around Rails tests, you can compare your app against the following pull requests when something does not match:

- **Setup** (default Rails authentication generator, recipe owners, guest-only UI)

  Open the PR [`setup-user-and-rails-8-auth` in PR #5](https://github.com/minitestrails/test-rails-default-auth-minitest/pull/5/changes?utm_source=minitestrails.com) and compare against your app to see the changes for the "setup" part of the Rails 8 authentication generator.

- **Tests** (Integration and System tests)

  Tests are in separate branch at `test-authentication`. You can checkout the PR [`test-authentication` in PR #6](https://github.com/minitestrails/test-rails-default-auth-minitest/pull/6/changes?utm_source=minitestrails.com) and compare against your app to see the changes for the "tests" part of the Rails built-in authentication generator.

## What you are actually testing

Authentication splits into two jobs in tests:

1. Session endpoints: Does sign-in, sign-out, and bad passwords behave correctly over HTTP?

2. Protected controllers: Once a session exists, can the right user reach the right pages, and can guests get blocked?

Integration tests can cover both session endpoints and protected controllers while handling detailed edge cases. But sign-in is a business-critical flow so you still want to test it in a real browser with a System test as well. Integration proves the HTTP contract while system proves the form users actually click.

You can use the following table to decide which test type to use depending on what you are testing:

| Test type | Good for default Rails authentication |
| --- | --- |
| Model | `User` validations, `has_secure_password` (generated by the authentication generator) |
| Integration (session) | POST sign-in, bad password, DELETE sign-out |
| Integration (protected routes) | Redirect for guests. Allow signed-in users to reach the page. |
| System (session) | Sign-in form: labels, button, Turbo, and session cookie in the browser |
| System (protected routes) | Guest redirect in the browser. Allow signed-in users to reach the page. |
| POST `/session` before every protected test | Works, but slow. Use `sign_in_as` helper instead (more on this later) |

<%= render Shared::Tip.new(
  title: "What are protected routes?",
  markdown: "A protected route is a page or action that requires a user to be signed in before accessing the page. The generator adds `before_action :require_authentication` to every controller, so guests get redirected to sign-in unless you opt out with `allow_unauthenticated_access`.
  
  On the Cookbook app, `index` and `show` stay public. `new`, `create`, `edit`, `update`, and `destroy` are protected: unsigned visitors hit `new_session_url` when they try to access these pages or actions."
) %>

## What authentication generator already adds

The authentication generator has improved a lot since it was first introduced in
Rails 8. Previously it only used to generate code for the session handling but it
now also adds helpers related to the authentication for testing. You still need to wire your protected routes and add system tests yourself, but you do not need to invent `sign_in_as` or fixture patterns for authentication tests to work.

Let's quickly go through the changes the authentication generator makes to the app and the tests it adds so you have a mental model of what is going on before adding actual tests for authentication and protected routes.

### Generated test files

| File | What it gives your suite |
| --- | --- |
| `test/test_helpers/session_test_helper.rb` | `sign_in_as(user)` and `sign_out` for integration tests |
| `test/test_helper.rb` | `require_relative "test_helpers/session_test_helper"` |
| `test/fixtures/users.yml` | Users with a known `password_digest` (plaintext `password` in tests) |
| `test/controllers/sessions_controller_test.rb` | Sign-in page loads, valid/invalid create, destroy clears cookie |
| `test/controllers/passwords_controller_test.rb` | Password reset request, token edit, update |
| `test/models/user_test.rb` | Email normalization on `User` |
| `test/mailers/previews/passwords_mailer_preview.rb` | Preview reset email in development (not a test, but generated) |

### `sign_in_as`: the helper every protected test uses

This is the piece most people miss when they copy snippets from older blog posts but it's the most important one for signing in users in tests. `sign_in_as` helper was added in Rails 8.1.0 with the PR [Add login_as(user) testing helper when generating authentication](https://github.com/rails/rails/pull/53708?utm_source=minitestrails.com).

`sign_in_as` creates a `Session` row, sets `Current.session`, and copies a signed `session_id` into the test request's cookie jar. All without hitting the `post session_url` and full sign-in round trip. You can check the reference code generated by the authentication generator below:

```ruby
# test/test_helpers/session_test_helper.rb (generated by the authentication generator)
module SessionTestHelper
  def sign_in_as(user)
    Current.session = user.sessions.create!

    ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
      cookie_jar.signed[:session_id] = Current.session.id
      cookies["session_id"] = cookie_jar[:session_id]
    end
  end

  def sign_out
    Current.session&.destroy!
    cookies.delete("session_id")
  end
end

ActiveSupport.on_load(:action_dispatch_integration_test) do
  include SessionTestHelper
end
```

With this helper, you can now call `sign_in_as(users(:alice))` to sign the user in and test all protected routes.

One thing to note here is that the generator wires this helper into `ActionDispatch::IntegrationTest` only, see the line `ActiveSupport.on_load(:action_dispatch_integration_test)`. So, this helper only works in integration tests. For system tests, you will need to add your own version of `sign_in_as` helper at `ApplicationSystemTestCase` (covered later in the post).

<%= render Shared::Tip.new(
  title: "Why not POST sign-in every time?",
  markdown: "You *can* call `post session_url` with email and password before each test. It totally works but it makes tests slow and couples every protected test to the session form params. You should reserve real POST sign-in tests only for session related tests like `session_integration_test.rb`, that's where you test the sign-in form itself. Everywhere else, use `sign_in_as(users(:alice))` so you are testing the controller under test, not the sign-in form again."
) %>

### Generator tests vs what this blog post adds

The authentication generator ships with a good chunk of tests but it focuses on the `controller` part instead of the full request-response cycle that is covered normally by integration tests. The generator also doesn't add system tests nor tests for protected routes.

Below is the full list of what generator adds in `test/` folder and what you will add yourself following this blog post:

| Generator ships | This post adds |
| --- | --- |
| `sessions_controller_test.rb` (controller-level) | `session_integration_test.rb` (full HTTP sign-in/out flows) |
| `sign_in_as` for integration tests | our version of `sign_in_as` for system tests |
| `users.yml` with at least one user | `alice` / `bob` fixtures and `user: alice` on recipe fixtures |
| No system/integration authentication tests | `sessions_test.rb` (system test) and `sessions_integration_test.rb` for sign-in and sign-out |
| No protected routes tests | `recipes_integration_test.rb` and `recipes_test.rb` (system test) for guest and signed-in routes |

The generator does not wire app-specific rules like guest-only routes, recipe ownership, or conditional rendering in view files. That is your application code and this blog post assumes you have already added that code. You can refer to the [PR #5](https://github.com/minitestrails/test-rails-default-auth-minitest/pull/5/changes?utm_source=minitestrails.com) for the changes made to the Cookbook app to see how that setup looks like.

## The authentication setup we are testing

This blog post tests the Cookbook app **after** generator setup and recipe ownership from [PR #5](https://github.com/minitestrails/test-rails-default-auth-minitest/pull/5/changes?utm_source=minitestrails.com). The following features can be different for your app but the general idea is to have a setup where there is a clear separation between guest and signed-in users:

| Feature | Description |
| --- | --- |
| Guests can browse recipes | `index` and `show` are public |
| Signed-in users can create recipes | If user is signed in, they can create recipes |
| Recipe owner can edit or destroy their recipes | Only the recipe owner should be able to edit or destroy their recipes |


Your protected controller ends up roughly like this when you have guest-only routes and protected routes:

```ruby
# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: %i[show edit update destroy]
  before_action :ensure_recipe_owner, only: %i[edit update destroy]

  def create
    @recipe = current_user.recipes.build(recipe_params)
    # ...
  end

  # ...

  private

  def ensure_recipe_owner
    return if @recipe.user == current_user

    redirect_to @recipe, alert: "You are not authorized to modify this recipe."
  end
end
```

Notice the `current_user` instead of `Current.user`? That is a Devise styled helper to retrieve the current user from the session. I added it to the Authentication concern separately in addition to what is generated by the authentication generator because I like it more than the repeated `Current.user` calls, you can also add it to the Authentication concern if you prefer:

```ruby
# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    # ...
    helper_method :current_user, :authenticated?
  end

  # ...

  private

  # ...

  def current_user
    return nil unless authenticated?

    @current_user ||= Current.user
  end

  # ...
end
```

Other helper methods generated by the authentication generator can vary slightly depending on the Rails version you are using, you should adjust as required. Here is what matters for the tests we will add:

| Piece | Role in tests |
| --- | --- |
| `User` + fixtures | `users(:alice)` is a real row with a known password |
| `Session` | Created by `sign_in_as`; stored id goes in the session cookie |
| `Current.user` / `current_user` | What controllers read after sign-in |
| Guest-only routes | `GET index` and `GET show` succeed without `sign_in_as` |
| Protected routes | `new`, `create`, `edit`, `update`, `destroy` redirect guests to sign-in |

## Fixtures

Before you write any test, ensure you have setup the fixture for the users required for the tests. Following is the sample fixture for the users taken from the Cookbook app (can be different for your app):

```yaml
# test/fixtures/users.yml
<%% password_digest = BCrypt::Password.create("password", cost: 4) %>

alice:
  email_address: alice@example.com
  password_digest: <%%= password_digest %>

bob:
  email_address: bob@example.com
  password_digest: <%%= password_digest %>
```

You can omit `cost: 4` if you want and use BCrypt's default (12). That still works but hashing is just slower at load time and on every real sign-in test. For test environments, `cost: 4` is enough and keeps the suite fast.

Also ensure you have user associated with the fixture that will establish ownership rules for the record. This can be different for you in your Rails app but in the Cookbook app, the `recipes` fixture has the `user` association set to `users(:alice)` and that's what we will be using for the tests in this post:

```yaml
# test/fixtures/recipes.yml
pancakes:
  title: Pancakes
  description: Fluffy buttermilk pancakes
  servings: 4
  prep_time: 15
  user: alice

lentil_soup:
  title: Lentil soup
  description: Simple dinner
  prep_time: 30
  servings: 6
  user: bob
```

So much of implementation details, let's finally write some tests!

## Testing authentication (user can sign in and sign out of the app)

You will start by testing the session controller responsible for handling the sign-in and sign-out functionality. We will split tests for each feature into an integration test and a system test (for this and all upcoming tests in this blog post).

Let's first look at the reference code extracted from the Cookbook app that was generated by the authentication generator and then we will start with integration tests:

```ruby
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path, status: :see_other
  end
end
```

### Integration test

Here is what you need to test in integration tests for the sessions controller:

| Test | What it proves |
| --- | --- |
| Signs in with valid credentials | Good email and password establish a session and redirect to the root path (recipe page here) |
| Rejects invalid password | Wrong password redirects back to sign-in; no session is created |
| Signs out | DELETE session clears access |

Create a new file from the terminal with `nano test/integration/session_integration_test.rb` and add the following tests to it:

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

class SessionIntegrationTest < ActionDispatch::IntegrationTest
  test "signs in" do
    get new_session_url
    assert_response :success

    post session_url, params: {
      email_address: users(:alice).email_address,
      password: "password"
    }
    assert_redirected_to root_url
    follow_redirect!
    assert_response :success
  end

  test "rejects invalid password" do
    post session_url, params: {
      email_address: users(:alice).email_address,
      password: "wrong"
    }
    assert_redirected_to new_session_url
    follow_redirect!
    assert_select "form"
  end

  test "signs out" do
    sign_in_as users(:alice)

    delete session_url
    assert_redirected_to new_session_url
  end
end
```

Here is what's happening in the test above:

1. `test "signs in"`

    - `get new_session_url` opens the sign-in form, this ensures the form is rendered correctly.
    - `post session_url` with the valid credentials signs the user in to the app.
    - `follow_redirect!` follows the redirect to the root URL and asserts the response is successful.

2. `test "rejects invalid password"`

    - `post session_url` with the invalid password.
    - `assert_redirected_to new_session_url` matches the generated controller: bad credentials redirect to the sign-in page with a flash alert, instead of a `422 (unprocessable entity)` re-render.
    - `follow_redirect!` then `assert_select "form"` proves the sign-in form is rendered back due to the invalid password.

3. `test "signs out"`

    - `sign_in_as users(:alice)` signs the user in to the app using the `sign_in_as` helper.
    - `delete session_url` signs the user out and redirects to the sign-in URL.


Run the test, you should get 0 failures and 0 errors.

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

### System test

Integration tests already proved the sign-in and sign-out functionality works over HTTP using the full request-response cycle. With the system test, we can test the same functionality in the browser and build more confidence in the authentication functionality.

Here is what you need to test for the session controller in the browser:

| Test | What it proves |
| --- | --- |
| Signs in through the form | Labels, button, and session cookie work with Capybara |
| Signs out through the form | Labels, button, and session cookie work with Capybara |

Create a new file from the terminal with `nano test/system/sessions_test.rb` and add the following tests to it:

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

class SessionsTest < ApplicationSystemTestCase
  test "signs in and out of the app" do
    visit new_session_url
    fill_in "Enter your email address", with: users(:alice).email_address
    fill_in "Enter your password", with: "password"
    click_on "Sign in"

    assert_selector "h1", text: "Recipes"
    assert_text "Sign out"

    click_on "Sign out"
    refute_selector "h1", text: "Recipes"
    assert_button "Sign in"
  end
end
```

We have merged the sign-in and sign-out flow into a single test for simplicity. Here is what's happening in the test above:

- `visit new_session_url` visits the sign-in page.
- `fill_in "..."` fills in the email and password fields with the user's credentials.
- `click_on "Sign in"` clicks the sign-in button.
- `assert_selector "h1", text: "Recipes"` asserts the page title is "Recipes" after signing in. The user is redirected to the root URL after signing in based on the code at `after_authentication_url` in the `app/controllers/concerns/authentication.rb` file.
- `assert_text "Sign out"` asserts the sign-out button is visible in the page after signing in.
- `click_on "Sign out"` clicks the sign-out button.
- `refute_selector "h1", text: "Recipes"` asserts the page title is not "Recipes" after signing out.
- `assert_button "Sign in"` asserts the sign-in button is present after signing out.

Run the test, you should get 0 failures and 0 errors.

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

If you are getting errors like the one below in the `fill_in` line, you will need to inspect the sign-in form and ensure the field names and labels match the ones in the test.

```bash
E

Error:
SessionsTest#test_signs_in_and_out_of_the_app:
Capybara::ElementNotFound: Unable to find field "Email address" that is not disabled
    test/system/sessions_test.rb:7:in 'block in <class:SessionsTest>'
```

## Testing protected controllers (signed-in users can create a recipe)

Now that you have tested the session controller, you can move onto testing the protected routes for the recipes controller next. This is the test that matches the [Reddit thread question](https://www.reddit.com/r/rails/comments/1h9a4ju/how_to_test_controllers_protected_by_the_new_auth?utm_source=minitestrails.com) prompting me to write this blog post: controllers that call `require_authentication` to ensure only signed-in users can access the routes.

You will be testing the following sample controller from the Cookbook app in this blog post, if you have your own controller, you can use that instead, the gist is to test the protected routes for the recipes controller hidden behind the `before_action :require_authentication` check:

```ruby
# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: %i[show edit update destroy]
  before_action :ensure_recipe_owner, only: %i[edit update destroy]

  def index
    @recipes = Recipe.order(created_at: :desc)
  end

  def show
  end

  def new
    @recipe = Recipe.new
  end

  def edit
  end

  def create
    @recipe = current_user.recipes.build(recipe_params)

    if @recipe.save
      redirect_to @recipe, notice: "Recipe was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @recipe.update(recipe_params)
      redirect_to @recipe, notice: "Recipe was successfully updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @recipe.destroy
    redirect_to recipes_path,
                notice: "Recipe was successfully destroyed.",
                status: :see_other
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end

  def ensure_recipe_owner
    return if @recipe.user == current_user

    redirect_to @recipe, alert: "You are not authorized to modify this recipe."
  end

  def recipe_params
    params.require(:recipe).permit(:title, :description, :servings, :prep_time)
  end
end
```

In this section, you will focus on `index`, `show`, `new`, and `create` actions. For testing remaining actions `edit`, `update`, and `destroy`; there is a bonus authorization section (only the recipe owner can edit or destroy their recipe).

### Integration test

For testing protected routes, you will need to use the `sign_in_as` helper method, it makes the process of signing in a user for the test quick and easy. Here is what you need to test for the recipes controller:

| Test | What it proves |
| --- | --- |
| Guest can view the list | `GET index` works without a session |
| Guest can view show | `GET show` works without a session |
| Guest cannot create | `GET new` sends guests to sign-in. `POST create` does not add a row, redirects to sign-in |
| Guest cannot update | `PATCH update` does not change the title; redirects to sign-in |
| Guest cannot destroy | `DELETE destroy` does not remove the row; redirects to sign-in |
| Signed-in user can create | After `sign_in_as`, create succeeds |

Replace the content of the `test/integration/recipes_integration_test.rb` file with the following to include new tests:

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

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  test "visits the list" do
    get recipes_url
    assert_response :success
    assert_match "Recipes", response.body
    assert_select "#recipes div[id^='recipe_']", count: Recipe.count
  end

  test "visits the recipe detail" do
    get recipe_url(recipes(:pancakes))
    assert_response :success
    assert_match recipes(:pancakes).title, response.body
  end

  test "guest cannot create a recipe" do
    get new_recipe_url
    assert_redirected_to new_session_url

    assert_no_difference("Recipe.count") do
      post recipes_url, params: { recipe: { title: "Sneaky soup" } }
    end
    assert_redirected_to new_session_url
  end

  test "guest cannot update a recipe" do
    recipe = recipes(:pancakes)

    patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }
    assert_redirected_to new_session_url
    assert_equal "Pancakes", recipe.reload.title
  end

  test "guest cannot destroy a recipe" do
    recipe = recipes(:lentil_soup)

    assert_no_difference("Recipe.count") do
      delete recipe_url(recipe)
    end
    assert_redirected_to new_session_url
  end

  test "creates a recipe" do
    sign_in_as users(:alice)

    assert_difference("Recipe.count", 1) do
      post recipes_url, params: { recipe: { title: "Alice's salad" } }
    end

    recipe = Recipe.last
    assert_redirected_to recipe_url(recipe)
    assert_equal users(:alice), recipe.user
  end
end
```

Here is what's happening in the test above (repeated assertions are excluded):

1. `test "visits the list"`
   - `get recipes_url` visits the recipes list page.
   - `assert_response :success` asserts the response is successful.
   - `assert_match "Recipes", response.body` asserts the page title is "Recipes".
   - `assert_select "#recipes div[id^='recipe_']", count: Recipe.count` asserts the recipe count in the page matches the total number of recipes in the database.

2. `test "visits the recipe detail"`
   - The first two lines are similar to the previous test.
   - `assert_match recipes(:pancakes).title, response.body` asserts the recipe title is present in the response body.

3. `test "guest cannot create a recipe"`
   - `assert_redirected_to new_session_url` asserts the user is redirected to the sign-in page because the action is protected and accessible only to signed-in users.
   - `assert_no_difference("Recipe.count") do` asserts the recipe count does not change.
   - `post recipes_url, params: { recipe: { title: "Sneaky soup" } }` tries to create a recipe without a session.

4. `test "guest cannot update a recipe"` and `test "guest cannot destroy a recipe"`
   - No `sign_in_as` call: the actor stays a guest.
   - `assert_redirected_to new_session_url` and unchanged fixture data prove guests cannot modify recipes over HTTP.
   - `assert_equal "Pancakes", recipe.reload.title` asserts the recipe title has not changed.

5. `test "creates a recipe"`
   - `sign_in_as users(:alice)` signs the user in to the app using the `sign_in_as` helper added by the authentication generator.
   - `assert_difference("Recipe.count", 1) do` asserts the recipe count increases by 1.
   - `post recipes_url, params: { recipe: { title: "Alice's salad" } }` creates a recipe with the title "Alice's salad".
   - `assert_redirected_to recipe_url(recipe)` asserts the user is redirected to the recipe show page.
   - `assert_equal users(:alice), recipe.user` asserts the recipe owner is the signed-in user.

Run the test, you should get 0 failures and 0 errors.

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

### System test

Integration tests already proved guests get redirected and signed-in users can create recipes. System tests add the browser angle: can a guest browse list and view the recipe detail, and can Alice click through create after signing in?

Here is what you need to test for the recipes controller in the browser:

| Test | What it proves |
| --- | --- |
| Visits the list | Guest can browse the recipe list in the browser |
| Visits the recipe detail | Guest can open a recipe detail page |
| Creates a recipe | After signing in, the create flow (protected route) works in the browser |

### `sign_in_as` helper for system tests

`sign_in_as` helper method added by the Rails authentication generator only works for Integration tests because of the following line in the file `test/test_helpers/session_test_helper.rb`, i.e. it only includes the helper in the integration_test namespace:

```ruby
# test/test_helpers/session_test_helper.rb

ActiveSupport.on_load(:action_dispatch_integration_test) do
  include SessionTestHelper
end
```

If you want to use a similar method in system tests, you need to add your own version of `sign_in_as` helper method to `ApplicationSystemTestCase` file. For this, we will add a new method with a slight difference in the naming `sign_in_to_ui_as` to avoid confusion with the existing `sign_in_as` helper method.

Replace the content of the `test/application_system_test_case.rb` file with the following and introduce the new helper method for signing in users in system tests:

```ruby
# test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]

  teardown { Capybara.reset_sessions! }

  def sign_in_to_ui_as(user)
    Current.session = user.sessions.create!

    ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
      cookie_jar.signed[:session_id] = Current.session.id

      visit new_session_url

      page.driver.browser.manage.add_cookie(
        name: :session_id,
        value: cookie_jar[:session_id],
        sameSite: :Lax,
        httpOnly: true
      )
    end
  end
end
```

This new method is mostly similar to the `sign_in_as` method for integration tests, 
the only difference is that it uses the browser to sign in the user by setting the session cookie in the browser using Capybara's `page.driver.browser.manage.add_cookie` method.

Don't forget to add the `teardown { Capybara.reset_sessions! }` line to the `ApplicationSystemTestCase` file. It's needed because Capybara reuses a real browser session between tests in the same class. Without a reset, a `session_id` cookie from one test can leak into the next. Guest tests might suddenly look signed in, or Alice's session might carry over when you meant to test as Bob. `reset_sessions!` clears cookies and related browser state so each test starts from a clean slate.

<%= render Shared::Tip.new(
  title: "Why not use the sign-in form in every system test?",
  markdown: "`sign_in_to_ui_as` uses `page.driver.browser.manage.add_cookie` to set the session cookie in the browser instead of filling in the form and clicking Sign in button. Why?
  
  You already proved the form works in the sessions system test. And reusing that flow for every protected-route test only makes the suite slower and flakier. System tests are already slower and if you try to go through the sign in form in every protected-route test, you will make it painfully slower.
  
  This technique was copied from the now closed PR by Chris Oliver in the Rails repository: [Add System test support in session test helper](https://github.com/rails/rails/pull/55124/changes?utm_source=minitestrails.com)"
) %>

### Add tests

You can now sign in to the app using the `sign_in_to_ui_as` helper method in the system tests. Use this new helper method and add the tests for the recipes controller in the browser for protected routes. Replace the content in the `test/system/recipes_test.rb` file with the following to add new tests:

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

class RecipesTest < ApplicationSystemTestCase
  test "visits the list" do
    visit recipes_url
    assert_selector "h1", text: "Recipes"
    assert_text recipes(:pancakes).title
  end

  test "visits the recipe detail" do
    recipe = recipes(:lentil_soup)
    visit recipe_url(recipe)
    assert_text recipe.title
  end

  test "creates a recipe" do
    sign_in_to_ui_as users(:alice)

    visit recipes_url
    click_on "New recipe"
    fill_in "Title", with: "Alice's salad"
    click_on "Create Recipe"

    assert_text "Alice's salad"
  end
end
```

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: "Integration tests own redirect status codes and row counts, add a detailed edge case in the integration test while keeping system tests smoke styled. System tests only need to prove the browser path works once per action you care about with the JavaScript enabled."
) %>

I like to keep system tests short, this is why you are only testing the most happiest path like listing, showing a recipe, and creating a recipe. Other actions like guest not being able to access the new recipe page is omitted intentionally. This is because you have already tested the guest not being able to access the new recipe page in the integration test and know they work; you don't need to test all edge cases again in the system test.

Here is what's happening in the test above:

1. `test "visits the list"`
   - `visit recipes_url` visits the recipes list page.
   - `assert_selector "h1", text: "Recipes"` asserts the page has an h1 with the text "Recipes".
   - `assert_text recipes(:pancakes).title` asserts the page has the pancakes recipe in the list.

2. `test "visits the recipe detail"`
   - `visit recipe_url(recipe)` visits the recipe detail page.
   - `assert_text recipe.title` asserts the page has lentil soup recipe in the detail page.

3. `test "creates a recipe"`
   - `sign_in_to_ui_as users(:alice)` signs the user in to the app using the new `sign_in_to_ui_as` helper we added above.
   - `visit recipes_url` visits the recipes list page.
   
      We are visiting the list page instead of directly visiting the form for new recipe so we can test the full flow of creating a recipe as this is how users normally interact with the app.
   - `click_on "New recipe"` clicks the button to open the form for new recipe.
   - `fill_in "Title", with: "Alice's salad"` fills in the title field with "Alice's salad".
   - `click_on "Create Recipe"` clicks the create button to submit the form to the server.
   - `assert_text "Alice's salad"` asserts the page has the new recipe's title "Alice's salad" in the recipe list.

Run the test, you should get 0 failures and 0 errors.

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

## Bonus: testing authorization (owners can edit and destroy their recipe)

Authentication answers who is signed in, that part is now handled and you can basically skip this section if you only came for "how to test protected routes hidden behind the Rails built-in authentication". Stick around if you want to also see how to test the authorization rules for the recipes controller.

This is a bonus section to test the authorization rules for the recipes controller. Authorization answers what that signed-in user is allowed to do. On the Cookbook app, any signed-in user can create a recipe but only the owner should be able to edit or destroy the recipe.

In production applications, you should use Pundit or CanCanCan for authorization. But we will keep it small and simple for this blog post: a `before_action` that compares `@recipe.user` to `current_user`, plus a change in the show template to only render Edit and Destroy buttons for the owner.

Here is what we added in the controller to ensure only the recipe owner can edit or destroy the recipe:

```ruby
# app/controllers/recipes_controller.rb
before_action :ensure_recipe_owner, only: %i[edit update destroy]

def ensure_recipe_owner
  return if @recipe.user == current_user

  redirect_to @recipe, alert: "You are not authorized to modify this recipe."
end
```

In the show view, we are only rendering the Edit and Destroy buttons for the owner:

```erb
<%%# app/views/recipes/show.html.erb %>
<%% if current_user == @recipe.user %>
  <%%= link_to "Edit this recipe", edit_recipe_path(@recipe) %>
  <%%= button_to "Destroy this recipe", @recipe, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<%% end %>
```

You need to also ensure you have two fixture users wired to the recipes fixture. `pancakes` belongs to `alice` who can edit and destroy her own recipe; that's the allow path. `bob` is a signed-in user who is not the owner and cannot edit or destroy Alice's recipe; that's the deny path.

### Integration test

Here is what you need to test for the recipes controller via HTTP request-response cycle:

| Test | What it proves |
| --- | --- |
| Non-owner cannot update recipe | Bob is signed in but GET edit redirects back to show. Bob PATCH does not change Alice's title |
| Non-owner cannot destroy | Bob DELETE does not remove Alice's recipe |
| Owner can update | Alice edits her own recipe |
| Owner destroys a recipe | Alice DELETE removes her row |

Update the `test/integration/recipes_integration_test.rb` file to include tests for recipe ownership (authorization):

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

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  test "non-owner cannot update a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)

    get edit_recipe_url(recipe)
    assert_redirected_to recipe_url(recipe)
    assert_equal "Pancakes", recipe.reload.title

    patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }
    assert_redirected_to recipe_url(recipe)
    assert_equal "Pancakes", recipe.reload.title
  end

  test "non-owner cannot destroy a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)

    assert_no_difference("Recipe.count") do
      delete recipe_url(recipe)
    end
    assert_redirected_to recipe_url(recipe)
    assert_equal "Pancakes", recipe.reload.title
  end

  test "updates a recipe" do
    sign_in_as users(:alice)
    recipe = recipes(:pancakes)

    get edit_recipe_url(recipe)
    assert_response :success

    patch recipe_url(recipe), params: { recipe: { title: "Updated pancakes" } }
    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_match "Updated pancakes", response.body
  end

  test "destroys a recipe" do
    sign_in_as users(:alice)
    recipe = recipes(:pancakes)

    assert_difference("Recipe.count", -1) do
      delete recipe_url(recipe)
    end
    assert_redirected_to recipes_url
    refute_match recipes(:pancakes).title, response.body
  end
end
```

Here is what's happening in the test above (repeated assertions are excluded):

1. `test "non-owner cannot update a recipe"`
   - It signs in the user bob to the app, visits the edit recipe page and asserts the user is redirected to the recipe show page.
   - `assert_equal "Pancakes", recipe.reload.title` asserts the recipe title is still "Pancakes". We are calling title with `reload` to ensure we are getting the latest data from the database.
   - `assert_no_difference("Recipe.count") do` asserts the recipe count does not change.
   - `patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }` updates the recipe title to "Hacked pancakes".

2. `test "non-owner cannot destroy a recipe"`
   - Most of the assertions are similar to the previous test.
   - `delete recipe_url(recipe)` destroys the recipe.
   - We are also testing the response body to ensure the recipe title is still "Pancakes".

3. `test "updates a recipe"`
   - It signs the user alice to the app, visits the edit recipe page and asserts the response is successful.
   - `patch recipe_url(recipe), params: { recipe: { title: "Updated pancakes" } }` updates the recipe title to "Updated pancakes".
   - `assert_redirected_to recipe_url(recipe)` asserts the user is redirected to the recipe show page.
   - `follow_redirect!` follows the redirect after successfully updating the recipe.
   - `assert_match "Updated pancakes", response.body` asserts the response body contains the updated recipe title text "Updated pancakes".

4. `test "destroys a recipe"`
   - `assert_difference("Recipe.count", -1) do` asserts the recipe count decreases by 1 i.e. recipe has been destroyed from the database.
   - `delete recipe_url(recipe)` destroys the recipe.
   - `assert_redirected_to recipes_url` asserts the user is redirected to the recipes list page.
   - `refute_match recipes(:pancakes).title, response.body` asserts the response body does not contain the recipe title "Pancakes" since that recipe has now been deleted.

Run the test, you should get 0 failures and 0 errors.

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

### System test

Integration tests proved HTTP deny and allow paths in detail. For system tests, we only need to test the happy path for the owner: they can update and destroy their own recipe.

| Test | What it proves |
| --- | --- |
| Owner can update a recipe | Alice can update her own recipe |
| Owner can destroy a recipe | Alice can destroy her own recipe |

Update the `test/system/recipes_test.rb` file to include tests for edit and destroy:

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

class RecipesTest < ApplicationSystemTestCase
  # ... existing tests ...

  test "updates a recipe" do
    sign_in_to_ui_as users(:alice)

    visit recipe_url(recipes(:pancakes))
    assert_text recipes(:pancakes).title
    click_on "Edit this recipe"
    fill_in "Title", with: "Updated pancakes"
    click_on "Update Recipe"
    assert_text "Updated pancakes"
  end

  test "destroys a recipe" do
    sign_in_to_ui_as users(:alice)

    visit recipe_url(recipes(:pancakes))
    assert_text recipes(:pancakes).title
    accept_confirm { click_on "Destroy this recipe" }
    assert_text "Recipe was successfully destroyed"
    refute_text recipes(:pancakes).title
  end
end
```

Here is what's happening in the test above:

1. `test "updates a recipe"`
   - The test signs the user alice to the app, visits the recipe show page and asserts the page has the pancakes recipe.
   - `click_on "Edit this recipe"` clicks the button to open the form for editing the recipe.
   - `fill_in "Title", with: "Updated pancakes"` fills in the title field with "Updated pancakes".
   - `click_on "Update Recipe"` clicks the update button to submit the form to the server.
   - `assert_text "Updated pancakes"` asserts the page has the text "Updated pancakes".

2. `test "destroys a recipe"`
   - `accept_confirm { click_on "Destroy this recipe" }` clicks the destroy recipe button and accepts the confirm dialog.
   - `assert_text "Recipe was successfully destroyed"` asserts the page has the text "Recipe was successfully destroyed".
   - `refute_text recipes(:pancakes).title` asserts the page does not have the text of the pancakes recipe.

Run the test, you should get 0 failures and 0 errors.

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

That is a very simple example of testing authorization. For an app with more complex authorization rules (guest rules, Pundit-style policies, more deny actors), see [authorization testing](/guide/authorization-testing/) in the guide.

## What to commit

Run the full suite to ensure all tests are passing:

```bash
bin/rails test:all
```

Commit the changes once you have 0 failures and 0 errors.

```bash
git add test/
git commit -m "Add Rails 8 authentication tests"
```
    
## Recap

Rails 8 authentication tests split into session flows, protected routes, and optionally ownership. Here is everything we added in this blog post:

- Add `users.yml` fixtures with `BCrypt::Password.create("password", cost: 4)` (`alice` and `bob`).
- Test sign-in, bad password (redirect to sign-in, no cookie), and sign-out in `session_integration_test.rb` with real POSTs.
- Guest integration tests: list and recipe detail work while `new`, `create`, `update`, and `destroy` redirect to sign-in without a session.
- Signed-in integration tests: call `sign_in_as(users(:alice))` then `creates a recipe`.
- System test (session): `signs in and out of the app` through the form in a browser.
- System test (protected routes): `visits the list`, `visits the recipe detail`, and `creates a recipe` using `sign_in_to_ui_as` for the write path.
- Bonus authorization (integration): `non-owner cannot update a recipe`, `non-owner cannot destroy a recipe`, `updates a recipe`, and `destroys a recipe` in `recipes_integration_test.rb`.
- Bonus authorization (system): `updates a recipe` and `destroys a recipe` in `recipes_test.rb` using `sign_in_to_ui_as`.

Start with session integration tests, then protected-route integration tests, then system tests, then ownership if you added `ensure_recipe_owner`. All of them in the order of: heavy integration tests and then light smoke-styled system tests.

Integration tests carry the weight (redirects, row counts, guest deny paths, non-owner HTTP). Prove each critical flow once in a real browser without replaying every integration edge case in system tests. Together they answer "can users sign in?", "does the app block guests?", "does the sign-in form work in a browser?", and "can only the owner change a recipe?"

## Where to go next

This post covers authentication (who is signed in) and a small authorization slice (recipe owner only). For a detailed policy setup using the Pundit gem and richer rules, you can check out the following chapters in the free Minitest Rails guide:

- [Authentication testing](/guide/authentication-testing/): full authentication chapter with TDD red/green on the Cookbook app.
- [Authorization testing](/guide/authorization-testing/): full ownership chapter with TDD red/green on the Cookbook app.
- [Running tests in CI](/guide/running-tests-in-ci/): run the same `bin/rails test` and `bin/rails test:all` commands in your CI to give you confidence in your tests.

Thanks for reading! If you have any questions or feedback, please let me know.

Happy testing!

## References

- [Rails 8 authentication generator](https://guides.rubyonrails.org/generators.html?utm_source=minitestrails.com#authentication-generator)
- [Reddit: testing controllers protected by the new auth](https://www.reddit.com/r/rails/comments/1h9a4ju/how_to_test_controllers_protected_by_the_new_auth?utm_source=minitestrails.com)
- [Add System test support in session test helper](https://github.com/rails/rails/pull/55124/changes?utm_source=minitestrails.com)
