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)](/guide/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](https://github.com/minitestrails/cookbook-user-registration?utm_source=minitestrails.com), based on the [Cookbook app](/guide/setting-up-minitest/#generate-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:

```bash
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](https://github.com/minitestrails/cookbook-user-registration/commit/6b9bd1545e7fc34cad531553c1ed4273b4fb81b3?utm_source=minitestrails.com) 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](https://github.com/minitestrails/cookbook-user-registration/commit/15bd6f8cfee1d0e329e642ffd2da5929fab5b988?utm_source=minitestrails.com). 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](https://guides.rubyonrails.org/sign_up_and_settings.html?utm_source=minitestrails.com). 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`:

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

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

```ruby
# 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](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html?utm_source=minitestrails.com#method-i-has_secure_password)).

Run the test, you should see 0 failures and 0 errors:

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

```ruby
# 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](https://guides.rubyonrails.org/sign_up_and_settings.html?utm_source=minitestrails.com#requiring-unauthenticated-access)). It looks like this:

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

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

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

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

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

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

## What to commit

Run the full suite to make sure everything is green before committing:

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

When that is green, commit your changes:

```bash
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](/blog/testing-rails-8-authentication-minitest/): session fixtures, `sign_in_as`, protected routes, and browser sign-in.
- [Testing authentication in Rails](/guide/testing-authentication/): full guide chapter on sessions and guest read-only access.
- [Authorization testing in Rails](/guide/authorization-testing/): 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

- [Sign up and settings](https://guides.rubyonrails.org/sign_up_and_settings.html?utm_source=minitestrails.com)
