Blog

How to Test Rails Built-in Authentication with Minitest

Test Rails built-in authentication in Minitest: session fixtures, sign_in_as helpers, protected route integration tests, and system tests for sign-in and protected controllers.

authentication rails-8 minitest rails integration-tests system-tests

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. 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. If you want the full guide chapter with ownership rules (authorization) included, see testing authentication and 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 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 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:

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 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 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)

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.

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:

# 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).

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 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. 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:

# 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:

# 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):

# 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:

# 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:

# 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:

# 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.

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:

# 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.

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.

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 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:

# 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:

# 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.

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:

# 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:

# 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.

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:

# 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

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.

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:

# 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:

<%# 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):

# 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.

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:

# 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.

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 in the guide.

What to commit #

Run the full suite to ensure all tests are passing:

bin/rails test:all

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

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:

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

Happy testing!

References #

Disclaimer: This blog is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.