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.
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 authenticationalready run. - A
Usermodel withemail_addressandpassword_digest, plus aSessionmodel (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-authin 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 PRtest-authenticationin 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:
-
Session endpoints: Does sign-in, sign-out, and bad passwords behave correctly over HTTP?
-
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:
-
test "signs in"get new_session_urlopens the sign-in form, this ensures the form is rendered correctly.post session_urlwith 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.
-
test "rejects invalid password"post session_urlwith the invalid password.assert_redirected_to new_session_urlmatches the generated controller: bad credentials redirect to the sign-in page with a flash alert, instead of a422 (unprocessable entity)re-render.follow_redirect!thenassert_select "form"proves the sign-in form is rendered back due to the invalid password.
-
test "signs out"sign_in_as users(:alice)signs the user in to the app using thesign_in_ashelper.delete session_urlsigns 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_urlvisits 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 atafter_authentication_urlin theapp/controllers/concerns/authentication.rbfile.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):
test "visits the list"get recipes_urlvisits the recipes list page.assert_response :successasserts the response is successful.assert_match "Recipes", response.bodyasserts the page title is “Recipes”.assert_select "#recipes div[id^='recipe_']", count: Recipe.countasserts the recipe count in the page matches the total number of recipes in the database.
test "visits the recipe detail"- The first two lines are similar to the previous test.
assert_match recipes(:pancakes).title, response.bodyasserts the recipe title is present in the response body.
test "guest cannot create a recipe"assert_redirected_to new_session_urlasserts 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") doasserts the recipe count does not change.post recipes_url, params: { recipe: { title: "Sneaky soup" } }tries to create a recipe without a session.
test "guest cannot update a recipe"andtest "guest cannot destroy a recipe"- No
sign_in_ascall: the actor stays a guest. assert_redirected_to new_session_urland unchanged fixture data prove guests cannot modify recipes over HTTP.assert_equal "Pancakes", recipe.reload.titleasserts the recipe title has not changed.
- No
test "creates a recipe"sign_in_as users(:alice)signs the user in to the app using thesign_in_ashelper added by the authentication generator.assert_difference("Recipe.count", 1) doasserts 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.userasserts 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:
test "visits the list"visit recipes_urlvisits the recipes list page.assert_selector "h1", text: "Recipes"asserts the page has an h1 with the text “Recipes”.assert_text recipes(:pancakes).titleasserts the page has the pancakes recipe in the list.
test "visits the recipe detail"visit recipe_url(recipe)visits the recipe detail page.assert_text recipe.titleasserts the page has lentil soup recipe in the detail page.
test "creates a recipe"sign_in_to_ui_as users(:alice)signs the user in to the app using the newsign_in_to_ui_ashelper we added above.-
visit recipes_urlvisits 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):
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.titleasserts the recipe title is still “Pancakes”. We are calling title withreloadto ensure we are getting the latest data from the database.assert_no_difference("Recipe.count") doasserts the recipe count does not change.patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }updates the recipe title to “Hacked pancakes”.
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”.
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.bodyasserts the response body contains the updated recipe title text “Updated pancakes”.
test "destroys a recipe"assert_difference("Recipe.count", -1) doasserts 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_urlasserts the user is redirected to the recipes list page.refute_match recipes(:pancakes).title, response.bodyasserts 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:
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”.
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).titleasserts 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.ymlfixtures withBCrypt::Password.create("password", cost: 4)(aliceandbob). - Test sign-in, bad password (redirect to sign-in, no cookie), and sign-out in
session_integration_test.rbwith real POSTs. - Guest integration tests: list and recipe detail work while
new,create,update, anddestroyredirect to sign-in without a session. - Signed-in integration tests: call
sign_in_as(users(:alice))thencreates a recipe. - System test (session):
signs in and out of the appthrough the form in a browser. - System test (protected routes):
visits the list,visits the recipe detail, andcreates a recipeusingsign_in_to_ui_asfor the write path. - Bonus authorization (integration):
non-owner cannot update a recipe,non-owner cannot destroy a recipe,updates a recipe, anddestroys a recipeinrecipes_integration_test.rb. - Bonus authorization (system):
updates a recipeanddestroys a recipeinrecipes_test.rbusingsign_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: full authentication chapter with TDD red/green on the Cookbook app.
- Authorization testing: full ownership chapter with TDD red/green on the Cookbook app.
- Running tests in CI: run the same
bin/rails testandbin/rails test:allcommands 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 #
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.