How to Test User Registration with Minitest Rails
Test user sign-up in Rails with Minitest: model validations, integration coverage for valid and invalid registration, with a system smoke test for the happy path.
Rails 8’s bin/rails generate authentication gives you sign-in, sign-out, and
password reset but it does not give you a way to register new users to the
app. Almost all of the apps still need a “create an account” flow so people can sign in with credentials they chose themselves.
We already covered sign-in, sign-out, and password reset in Chapter 12 (Testing authentication) of the Minitest Rails guide. In this post you will add registration on top of that Rails authentication setup, then test it with model coverage, integration coverage, and a smoke styled system test.
Assumptions #
This walkthrough assumes:
- Rails 8 app with
bin/rails generate authenticationalready run. - User registration setup so that a guest can visit the sign-up form, fill in valid credentials, and land in the home page as a signed-in user.
- User fixture so at least one user exists in the database and can be used in tests.
Tested and working in #
The code in this post is tested and working in:
- Ruby 4.0.6
- Rails 8.1.3
- Minitest 6.0.6
- selenium-webdriver 4.47.0
Sample companion app #
Every code block in this post comes from a runnable Rails app, cookbook-user-registration, based on the Cookbook app from the Minitest Rails guide.
The companion app comes with registration already set up so you can just follow the post and start testing right away.
If you are new to testing Rails apps with Minitest, I recommend you clone the app and follow along locally:
git clone git@github.com:minitestrails/cookbook-user-registration.git
cd cookbook-user-registration
bin/setup --skip-server
bin/rails test:all
You should see a green suite when running all tests. It’s now ready for you to add tests required for the registration flow.
If you already know your way around Rails tests, you can compare your app against the companion repo when something does not match or if the tests we add later fail for some reason:
-
Setup (full registration wiring based on the official Rails guide)
Open the commit diff 6b9bd15 and compare against your app to see the registration wiring.
-
Tests (model, integration, and system tests)
Compare your local
test/files against the tests added in the commit 15bd6f8. This post’s examples match that commit.
What you are actually testing #
When a user registers to your app, you want to prove a new user row is created, validations catch bad input, and a successful create logs in the user to the app in one request.
| Test type | Good for registration |
|---|---|
| Model | Email presence and uniqueness, password confirmation mismatch, fixture still valid |
| Integration | Form loads, valid POST creates a user and session (user logged in to the app), invalid and duplicate POSTs return 422 (unprocessable entity) with no new row, signed-in users redirect back to root path |
| System | One browser happy path through the Sign up form |
| Filling the sign-up form before every later protected test | Skip it. Use sign_in_as once registration itself is covered. |
You will cover the edge cases in integration tests to ensure the registration is working end to end. And you will keep one browser smoke test so you also prove the sign-up form works in a real browser.
Assumptions for the user registration setup #
The Rails authentication generator does not add sign-up so you need to configure it yourself. Luckily, the official Rails guide covers that in Sign up and settings. The companion app for this post is loosely based on that guide; though it only adds a minimal set of code required to test the registration flow instead of adding everything from that guide.
You can skip this section if you are using the companion app from this post to follow along. But if you are adding tests to your own production app, here’s what this post assumes regarding the user registration setup. You need these to be in place for the tests to work:
Usermodel (app/models/user.rb) hashas_secure_password,has_many :sessions, email normalization, andvalidates :email_address, presence: true, uniqueness: true.- Singular
resource :sign_upmaps toSignUpsController: guests useGET /sign_up(show the form) andPOST /sign_up(create). - A signed-in user is not allowed to visit the sign-up form, they are redirected to the root path if they try to visit it.
- A successful create starts a session and redirects the user to the root path.
- A failed create re-renders the Sign up form with
422unprocessable entity status. - The page heading for the registration page is
Sign Up. Required form labels areEmail address,Password, andPassword confirmation(first or last name fields are optional). The submit button text isSign up. - A user fixture named
aliceexists with the emailalice@example.comand passwordpasswordinside thetest/fixtures/users.ymlfile.
Model test: registration validations #
Finally, it’s time to write some tests. You will start with the model tests and move on to comprehensive integration tests covering all edge cases then wrap up this blog with a system test to ensure the sign-up form works in a browser.
User model you are testing #
The user model is mostly similar to the one generated by the Rails authentication generator. The only change from the authentication generator is the addition of presence and uniqueness validations on email_address:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
validates :email_address, presence: true, uniqueness: true
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
The database migration also adds the email_address column with a unique index, you will see this in the migration file: add_index :users, :email_address, unique: true.
Now, you might be wondering why you need to add validation when the database constraints already ensure uniqueness. The answer is that database constraints alone are not enough for a friendly form failure. Without the validation, a duplicate POST can raise and return a 500 (internal server error) instead of re-rendering the form with a 422 status (unprocessable entity).
Lastly, make sure you have Alice in the user fixture:
# test/fixtures/users.yml
alice:
email_address: alice@example.com
password: password
Scenarios to automate #
You will test and prove the following registration related validations:
| Test | What it proves |
|---|---|
is valid |
users(:alice) still passes valid?, ensures the fixture is valid |
rejects a duplicate email |
Alice’s email cannot be registered again |
rejects a blank email |
Requires email to be present for the user |
rejects a password confirmation mismatch |
password and password confirmation should match when registering a new user |
Add tests #
Open the User model test file at test/models/user_test.rb and add the following tests to cover the registration related validations:
# test/models/user_test.rb
require "test_helper"
class UserTest < ActiveSupport::TestCase
# ... existing tests ...
test "is valid" do
assert users(:alice).valid?
end
test "rejects a duplicate email" do
user =
User.new(
email_address: users(:alice).email_address,
password: "password",
password_confirmation: "password"
)
assert_not user.valid?
assert_includes user.errors[:email_address], "has already been taken"
end
test "rejects a blank email" do
user =
User.new(
email_address: "",
password: "password",
password_confirmation: "password"
)
assert_not user.valid?
assert_includes user.errors[:email_address], "can't be blank"
end
test "rejects a password confirmation mismatch" do
user =
User.new(
email_address: "newcook@example.com",
password: "password",
password_confirmation: "different"
)
assert_not user.valid?
assert_includes user.errors[:password_confirmation],
"doesn't match Password"
end
end
This is what’s happening in the code above:
test "is valid"loadsusers(:alice)and asserts the fixture still satisfies every validation. Put this first so a broken fixture fails early.test "rejects a duplicate email"builds a second user with the same email address as Alice and expects it to throw a validation error for a unique email.test "rejects a blank email"expects email address to be present.test "rejects a password confirmation mismatch"expects the password and password confirmation to match. The validation for password mismatch comes fromhas_secure_password(see: Rails API doc).
Run the test, you should see 0 failures and 0 errors:
bin/rails test test/models/user_test.rb
Integration test: sign-up over HTTP #
The model tests only covered the validations in Ruby. You still need to prove those errors show up when someone uses the registration feature. That’s where integration tests come in.
An integration test is a good fit here because it covers the full request and response cycle. It is close to a system test, but faster and without a real browser.
Controller you are testing #
The controller you will be testing for the registration flow is the SignUpsController. It’s a standard Rails controller with a show action that renders the sign-up form and a create action that creates a new user.
It should look similar to this:
# app/controllers/sign_ups_controller.rb
class SignUpsController < ApplicationController
unauthenticated_access_only
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to sign_up_path, alert: "Try again later." }
def show
@user = User.new
end
def create
@user = User.new(sign_up_params)
if @user.save
start_new_session_for(@user)
redirect_to root_path
else
render :show, status: :unprocessable_entity
end
end
private
def sign_up_params
params.expect(
user: %i[
email_address
password
password_confirmation
]
)
end
end
This is what the controller does:
unauthenticated_access_onlykeeps the form guest-only so the signed-in users cannot visit it.rate_limitis used to prevent abuse and make the registration form more resilient to spam.showaction renders the sign-up form.createaction creates a new user. It usessign_up_paramsto get the email address, password, and password confirmation from the request parameters.- If the user is saved successfully, it starts a new session for the user and redirects them to the root path.
- If the user is not saved successfully, it re-renders the sign-up form with a
422status and renders errors in the form.
If you are wondering about unauthenticated_access_only, it’s a helper method inside the Authentication concern at app/controllers/concerns/authentication.rb, the implementation comes from the official Rails guide (see: requiring unauthenticated access). It looks like this:
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
# ... existing code ...
class_methods do
# ... existing code ...
def unauthenticated_access_only(**options)
allow_unauthenticated_access **options
before_action -> { redirect_to root_path if authenticated? }, **options
end
end
# ... existing code ...
end
end
Scenarios to automate #
You will test the following registration flows with integration tests:
| Test | What it proves |
|---|---|
registers a user |
GET form succeeds, valid POST creates one user, redirects to root, and leaves the visitor signed in |
rejects invalid registration |
Blank email returns 422, no change in the user count, form will show errors |
rejects a duplicate email |
Using existing user’s (Alice) email returns 422, no change in the user count, form will show errors |
signed-in user cannot visit sign-up |
After signing in the user, GET and POST /sign_up redirect to root with no new user |
Add tests #
Create a new integration test file test/integration/sign_ups_integration_test.rb and add the following tests:
# test/integration/sign_ups_integration_test.rb
require "test_helper"
class SignUpsIntegrationTest < ActionDispatch::IntegrationTest
test "registers a user" do
get sign_up_url
assert_response :success
assert_select "h1", "Sign Up"
assert_select "form"
assert_difference("User.count", 1) do
post sign_up_url,
params: {
user: {
email_address: "newcook@example.com",
password: "password",
password_confirmation: "password"
}
}
end
assert_redirected_to root_path
follow_redirect!
assert_match "Sign out", response.body
user = User.find_by!(email_address: "newcook@example.com")
assert user.authenticate("password")
end
test "rejects invalid registration" do
assert_no_difference("User.count") do
post sign_up_url,
params: {
user: {
email_address: "",
password: "password",
password_confirmation: "password"
}
}
end
assert_response :unprocessable_entity
assert_select "h1", "Sign Up"
assert_select "form"
assert_match(/Error:/, response.body)
assert_match "can't be blank", CGI.unescapeHTML(response.body)
end
test "rejects a duplicate email" do
assert_no_difference("User.count") do
post sign_up_url,
params: {
user: {
email_address: users(:alice).email_address,
password: "password",
password_confirmation: "password"
}
}
end
assert_response :unprocessable_entity
assert_select "form"
assert_match(/Error:/, response.body)
assert_match(/has already been taken/i, response.body)
end
test "signed-in user cannot visit sign-up" do
sign_in_as users(:alice)
get sign_up_url
assert_redirected_to root_path
assert_no_difference("User.count") do
post sign_up_url,
params: {
user: {
email_address: "sneaky@example.com",
password: "password",
password_confirmation: "password"
}
}
end
assert_redirected_to root_path
end
end
This is what’s happening in the code above:
test "registers a user"get sign_up_urlopens the Sign Up page (SignUpsController#show). Form visit lives here, not in a separate visit-only test, because this page also POSTs a new user.assert_response :successplusassert_selecton the heading and form prove the view before submit.assert_difference("User.count", 1)wraps the POST and proves one row was created.- Nested
userparams matchform_with model: @user, url: sign_up_path. assert_redirected_to root_paththenfollow_redirect!lands on Cookbook home.assert_match "Sign out", response.bodyprovesstart_new_session_forsigned the new user in.User.find_by!plusauthenticateproves the password digest works for the new email.
test "rejects invalid registration"- Posts a blank email.
assert_no_difference("User.count")proves no row was created.assert_response :unprocessable_entityproves the422path.- The companion view prints
Error:plus the first full message, so the match looks for that shape instead of a<li>list. CGI.unescapeHTML(response.body)turns'back into a plain apostrophe before matching"can't be blank".
test "rejects a duplicate email"- Posts Alice’s email again.
- Same
422and no-count story, with the uniqueness message afterError:.
test "signed-in user cannot visit sign-up"sign_in_as users(:alice)establishes a session first.- GET and POST
/sign_upboth redirect to root. That is the behaviorunauthenticated_access_onlyadds. assert_no_difference("User.count")proves create never ran.
If you are not familiar with sign_in_as, it’s a helper method generated by the Rails authentication generator that signs the user into the app by setting session in the cookies. It lets you skip the hassle of manually signing in the user before each test for protected routes. It looks like this:
# test/test_helpers/session_test_helper.rb
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
# ... other code
end
Run the integration file, you should see 0 failures and 0 errors:
bin/rails test test/integration/sign_ups_integration_test.rb
System test: one browser smoke #
Integration already covered invalid input, duplicate email, and the signed-in deny. You can stop here if you want, your tests are already comprehensive enough to let you know the registration flow is working!
But I still like adding one browser smoke test just to ensure the registration form works as expected in a real browser and catch any regressions related to JavaScript.
Make sure to only test the happy path in the system test: the user opens the form, fills valid credentials, and lands signed in on the home page.
You might be wondering “why does the system test have to be smoke styled and only test the happy path?” The answer is that system tests are the slowest tests in the suite and the most likely to be flaky (fails often without a good reason). You have already covered everything with the integration tests so a smoke-styled test is enough to prove the browser path still works.
Create a new system test file at test/system/sign_ups_test.rb and add the following test:
# test/system/sign_ups_test.rb
require "application_system_test_case"
class SignUpsTest < ApplicationSystemTestCase
test "registers a user" do
visit sign_up_url
fill_in "Email address", with: "browsercook@example.com"
fill_in "Password", with: "password"
fill_in "Password confirmation", with: "password"
click_button "Sign up"
assert_text "Sign out"
end
end
This is what’s happening in the code above:
visit sign_up_urlopens the Sign Up page in a real browser.fill_inuses the same label text the view renders and fills in the form fields with credentials for a new user.click_button "Sign up"submits the form and creates a new user.assert_text "Sign out"proves the session started after creating a new user.
Run the system test, you should see 0 failures and 0 errors:
bin/rails test test/system/sign_ups_test.rb
What to commit #
Run the full suite to make sure everything is green before committing:
bin/rails test:all
When that is green, commit your changes:
git add test/models/user_test.rb test/integration/sign_ups_integration_test.rb test/system/sign_ups_test.rb
git commit -m "Add test coverage for user registration"
Conclusion #
Rails authentication generator is a positive step towards owning authentication in your app, but it does not give you registration that is required in almost all apps, so you wire it yourself. After that, you still need to prove the flow works so you add tests to cover the model validations, the HTTP flow, and one browser happy path.
Model tests cover the validations in Ruby. Integration tests cover invalid input, duplicate email, and the signed-in deny to the registration form. The system test stays small and only covers the browser happy path where a guest signs up and lands in the home page as a signed-in user.
Where to go next #
This post covers user registration with Minitest Rails. For sign-in and guest access and for ownership authorization after people can sign in, these are the next stops:
- How to Test Rails Built-in Authentication with Minitest: session fixtures,
sign_in_as, protected routes, and browser sign-in. - Testing authentication in Rails: full guide chapter on sessions and guest read-only access.
- Authorization testing in Rails: ownership rules after people can sign in (also a guide chapter).
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.