Blog

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.

authentication registration minitest rails integration-tests system-tests

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

  • User model (app/models/user.rb) has has_secure_password, has_many :sessions, email normalization, and validates :email_address, presence: true, uniqueness: true.
  • Singular resource :sign_up maps to SignUpsController: guests use GET /sign_up (show the form) and POST /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 422 unprocessable entity status.
  • The page heading for the registration page is Sign Up. Required form labels are Email address, Password, and Password confirmation (first or last name fields are optional). The submit button text is Sign up.
  • A user fixture named alice exists with the email alice@example.com and password password inside the test/fixtures/users.yml file.

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:

  1. test "is valid" loads users(:alice) and asserts the fixture still satisfies every validation. Put this first so a broken fixture fails early.
  2. 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.
  3. test "rejects a blank email" expects email address to be present.
  4. test "rejects a password confirmation mismatch" expects the password and password confirmation to match. The validation for password mismatch comes from has_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:

  1. unauthenticated_access_only keeps the form guest-only so the signed-in users cannot visit it.
  2. rate_limit is used to prevent abuse and make the registration form more resilient to spam.
  3. show action renders the sign-up form.
  4. create action creates a new user. It uses sign_up_params to get the email address, password, and password confirmation from the request parameters.
  5. If the user is saved successfully, it starts a new session for the user and redirects them to the root path.
  6. If the user is not saved successfully, it re-renders the sign-up form with a 422 status 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:

  1. test "registers a user"
    • get sign_up_url opens 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 :success plus assert_select on the heading and form prove the view before submit.
    • assert_difference("User.count", 1) wraps the POST and proves one row was created.
    • Nested user params match form_with model: @user, url: sign_up_path.
    • assert_redirected_to root_path then follow_redirect! lands on Cookbook home.
    • assert_match "Sign out", response.body proves start_new_session_for signed the new user in.
    • User.find_by! plus authenticate proves the password digest works for the new email.
  2. test "rejects invalid registration"
    • Posts a blank email.
    • assert_no_difference("User.count") proves no row was created.
    • assert_response :unprocessable_entity proves the 422 path.
    • 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 &#39; back into a plain apostrophe before matching "can't be blank".
  3. test "rejects a duplicate email"
    • Posts Alice’s email again.
    • Same 422 and no-count story, with the uniqueness message after Error:.
  4. test "signed-in user cannot visit sign-up"
    • sign_in_as users(:alice) establishes a session first.
    • GET and POST /sign_up both redirect to root. That is the behavior unauthenticated_access_only adds.
    • 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:

  1. visit sign_up_url opens the Sign Up page in a real browser.
  2. fill_in uses the same label text the view renders and fills in the form fields with credentials for a new user.
  3. click_button "Sign up" submits the form and creates a new user.
  4. 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:

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.