Testing mailers in Rails

You just developed a really cool recipe for some food, now you want to share it with your close friend. One way to do that is by copying the recipe URL and sending it to your friend via email. But wouldn’t it be even better if you could click a share button, input the email address of your friend, and have the recipe sent to them right from the app? That’s what we’re going to build in this chapter.

If you worked through the guide in order, you already saw how to share a recipe with a friend in Chapter 4 - How to approach testing. You filled a form, clicked send, and letter_opener opened the email preview in the browser. That was manual testing; this chapter converts those manual tests into automated tests so you can ensure the email goes out of the app and is delivered to the correct recipient.

You will not set up real email delivery here, which means no SMTP through Gmail, Bento, Sendgrid, or similar services. Production mail needs API keys, DNS, and other things that will be too much to cover in this guide.

What you will do in this chapter #

  1. Write guest and signed-in mailer tests, plus an integration test for the share recipe by email flow (red), then implement RecipeMailer#share.
  2. Add the mailer HTML template plus a POST-only share route, controller action, and a modal to share the recipe with a native <dialog> (green).
  3. Add a smoke system test for the share recipe by email flow.
  4. Add a mailer test for PasswordsMailer#reset from Chapter 12.
  5. Add mailer previews for the share recipe by email and password reset, then preview them both in the browser at /rails/mailers.
  6. Configure letter_opener, then click through share recipe and password reset flow in the real browser. Both emails open in a new browser tab instead of being sent to a real mailbox.
  7. Run the full test suite and commit after everything is green.

Scenarios to automate #

Scenario Expected
Share recipe email Guest share: one email to friend@example.com, heading uses “Someone has shared”, title and includes the URL to recipe show page.
Signed-in share: heading includes the email address of the user who shared the recipe
Share recipe over HTTP Guest and signed-in alice can both POST recipe[recipient_email]; one email each; redirect back to recipe show page
Share recipe in the browser Guest opens pancakes show, clicks Share by email, fills a friend address, submits, lands on the recipe show page with the success flash message
Password reset email PasswordsMailer#reset for alice has correct to, from, subject, and a /passwords/ link

What is a mailer? #

A mailer is a Ruby class under app/mailers/ that builds an email. Each action (method) sets instance variables that are used in a view under app/views/<mailer_name>/. Emails are sent when you call deliver_now (send immediately) or deliver_later (enqueue a job, Chapter 15) in any other parts of your app, though normally inside a controller or a background job.

Share a recipe by email #

You liked the recipe you just cooked and now want to share it with your friend. Here is how this feature can be implemented in the app:

  1. Button to share the recipe by email in the recipe show page.
  2. Form to share the recipe by email in the show page; form wrapped inside a native <dialog> element as a modal. Form will have one input field for the email address of the friend who will receive the recipe.
  3. Controller action to share the recipe by email inside the existing controller at app/controllers/recipes_controller.rb.
  4. Mailer to actually send the email when the recipe is shared (new mailer, share action and the corresponding view).

Sharing the recipe will be available to everyone in the app (both guests and signed-in users) which means no ownership check (no authorization) in the app/policies/recipe_policy.rb file.

You will start by writing the mailer and integration tests (red), then grow the mailer view, the POST share action, and a share dialog partial until all tests are green. Finally to wrap up the testing, you add the system smoke test to ensure the feature works in the browser.

What counts as working? #

Flow You might say
Share (mailer only) When you share pancakes with friend@example.com as a guest, the heading says someone shared it. When alice shares, the heading includes alice@example.com.
Share (full feature) Guest and signed-in alice both open pancakes show, POST a friend email, get one email and a redirect. Heading copy stays in the mailer tests.
Share (browser smoke) Guest opens pancakes, clicks Share by email, submits a friend address, sees the flash on show.

Add mailer scenarios to the test file #

Create a new test file with nano test/mailers/recipe_mailer_test.rb and add following scenarios:

# test/mailers/recipe_mailer_test.rb
require "test_helper"

class RecipeMailerTest < ActionMailer::TestCase
  include Rails.application.routes.url_helpers

  # Actor: mailer invoked from test (shared by signed-in user)
  # Starting point: recipes(:pancakes); users(:alice) email as sender
  # Action: build and deliver share with sender_email
  # Expected outcome: heading and body include alice's email address
  # test "shares a recipe" do
  # end

  # Actor: mailer invoked from test (shared by guest, no sender email)
  # Starting point: recipes(:pancakes) from fixtures
  # Action: build and deliver share recipe email to friend@example.com
  # Expected outcome: one email; correct to/from; title and path; "Someone has shared" heading
  # test "guest shares a recipe" do
  # end
end

Make sure to add include Rails.application.routes.url_helpers to the test file, that’s required for recipe_path(recipe) in the mailer tests you will use later.

Red: add the mailer tests #

Replace the bodies of both share tests with the following:

# test/mailers/recipe_mailer_test.rb
  test "shares a recipe" do
    recipe = recipes(:pancakes)
    sender = users(:alice).email_address
    email = RecipeMailer.share(recipe, "friend@example.com", sender)

    assert_emails 1 do
      email.deliver_now
    end

    assert_equal ["friend@example.com"], email.to
    assert_equal ["from@example.com"], email.from
    assert_includes email.subject, sender
    assert_includes email.body.encoded, sender
    assert_includes email.body.encoded, recipe.title
    assert_match recipe_path(recipe), email.body.encoded
  end

  test "guest shares a recipe" do
    recipe = recipes(:pancakes)
    email = RecipeMailer.share(recipe, "friend@example.com")

    assert_emails 1 do
      email.deliver_now
    end

    assert_equal ["friend@example.com"], email.to
    assert_equal ["from@example.com"], email.from
    assert_includes email.subject, "Someone has shared"
    assert_includes email.body.encoded, "Someone has shared"
    assert_includes email.body.encoded, recipe.title
    assert_match recipe_path(recipe), email.body.encoded
  end

This is what’s happening in the code above:

  • Both examples use RecipeMailer.share to build the email.
  • The first example passes Alice’s email as the third argument (edge case: a signed-in share). The heading includes the sender.
  • The second example covers the guest edge case. It passes two arguments to the mailer, so sender_email stays nil and the heading uses “Someone has shared…” because we don’t know who shared the recipe.
  • email.deliver_now inside assert_emails is the moment email is sent in the test environment.
  • assert_equal on email.to and email.from checks headers of the email being sent.
  • assert_includes on subject and email.body.encoded checks the heading copy for email subject and the body.
  • assert_match recipe_path(recipe), email.body.encoded checks the recipe show path in the email. That needs include Rails.application.routes.url_helpers on the test class because ActionMailer::TestCase does not include them by default.

Run the mailer file:

bin/rails test test/mailers/recipe_mailer_test.rb

The test should fail with an error like this:

E

Error:
RecipeMailerTest#test_shares_a_recipe:
NameError: uninitialized constant RecipeMailerTest::RecipeMailer
    test/mailers/recipe_mailer_test.rb:8:in 'block in <class:RecipeMailerTest>'

E

Error:
RecipeMailerTest#test_guest_shares_a_recipe:
NameError: uninitialized constant RecipeMailerTest::RecipeMailer
    test/mailers/recipe_mailer_test.rb:25:in 'block in <class:RecipeMailerTest>'

The test fails because the RecipeMailer has not been created/defined yet in the project. That’s a red for the RecipeMailer.

Red: add the integration tests #

Next up is adding the integration tests for the share recipe by email. These tests will ensure the email is sent and the user is redirected to the recipe show page; testing the full feature.

Open test/integration/recipes_integration_test.rb and append the following scenario comments to the end of the file:

# test/integration/recipes_integration_test.rb
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  # Actor: signed-in alice
  # Starting point: recipes(:pancakes) on show
  # Action: GET show, modal with share form present, POST friend email while signed in
  # Expected outcome: one email; redirect to show
  # test "shares a recipe" do
  # end

  # Actor: guest (not signed in)
  # Starting point: recipes(:pancakes) on show
  # Action: POST friend email
  # Expected outcome: one email; redirect to show
  # test "guest shares a recipe" do
  # end
end

Replace the bodies of both tests with the following:

# test/integration/recipes_integration_test.rb
  test "shares a recipe" do
    sign_in_as users(:alice)
    recipe = recipes(:pancakes)

    get recipe_url(recipe)
    assert_response :success
    assert_select "dialog"
    assert_select "input[name='recipe[recipient_email]']"

    assert_emails 1 do
      post share_recipe_url(recipe), params: { recipe: { recipient_email: "friend@example.com" } }
    end

    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_response :success
    assert_match recipe.title, response.body
  end

  test "guest shares a recipe" do
    recipe = recipes(:pancakes)

    assert_emails 1 do
      post share_recipe_url(recipe), params: { recipe: { recipient_email: "friend@example.com" } }
    end

    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_response :success
    assert_match recipe.title, response.body
  end

This is what’s happening in the code above:

  • test "shares a recipe" uses sign_in_as users(:alice) from Chapter 12 to sign Alice into the app. The guest example has no sign_in_as.
  • The first example test "shares a recipe" also checks the response status and the presence of the share form in the dialog. One of such checks is enough per feature so guest example doesn’t repeat those checks.
  • assert_emails proves recipe shared email is sent. It does not re-check heading copies or email content because you have already covered those in mailer tests.
  • assert_redirected_to and assert_match recipe.title, response.body ensure the user lands back on the recipe show page and sees the recipe title.

Run both tests for the share recipe feature:

bin/rails test test/integration/recipes_integration_test.rb -i "/shares_a_recipe/"

You should see a failure and an error like this:

F

Failure:
RecipesIntegrationTest#test_shares_a_recipe [test/integration/recipes_integration_test.rb:282]:
Expected at least 1 element matching "dialog", found 0.
Expected 0 to be >= 1.

E

Error:
RecipesIntegrationTest#test_guest_shares_a_recipe:
NoMethodError: undefined method 'share_recipe_url' for an instance of RecipesIntegrationTest
    test/integration/recipes_integration_test.rb:304:in 'block (2 levels) in <class:RecipesIntegrationTest>'
    test/integration/recipes_integration_test.rb:303:in 'block in <class:RecipesIntegrationTest>'

Both of these errors are related to the fact that the share recipe feature is not implemented yet. One fails due to the missing dialog markup and the other due to the missing route for the share action.

That is red for the integration layer. You will fix it in the next steps.

Green: Wire the mailer for sharing the recipe #

You have two things to fix: the mailer and the integration test. First, you will generate the mailer for sharing the recipe by email to fix the mailer test. Then wire the share recipe by email feature (controller + views) to fix the integration test.

Generate the mailer for sharing the recipe #

Generate the mailer action and the corresponding view file for sharing the recipe with the following command:

bin/rails generate mailer RecipeMailer share --no-test-framework

The generator adds the mailer class with the share action at app/mailers/recipe_mailer.rb, the mailer view file at app/views/recipe_mailer/share.html.erb, and a mailer preview at test/mailers/previews/recipe_mailer_preview.rb. You already wrote the mailer tests by hand so passing --no-test-framework tells the generator to skip the mailer test file.

Implement RecipeMailer#share #

Open app/mailers/recipe_mailer.rb and replace the content of the whole file with the following:

# app/mailers/recipe_mailer.rb
class RecipeMailer < ApplicationMailer
  def share(recipe, recipient, sender_email = nil)
    @recipe = recipe
    @sender_email = sender_email

    @heading =
      (
        if @sender_email
          "#{@sender_email} has shared a recipe with you"
        else
          "Someone has shared a recipe with you"
        end
      )

    mail to: recipient, subject: @heading
  end
end

This is what’s happening in the code above:

  • @recipe sets the recipe instance variable so it can be accessed later in the mailer view to display the recipe title and the URL to the recipe show page.
  • sender_email is the email address of the user who shared the recipe. If sender_email is not provided, the subject will be “Someone has shared a recipe with you”.
  • mail to: recipient, subject: ... sets headers for the email being sent.

RecipeMailer inherits from ApplicationMailer at app/mailers/application_mailer.rb, so it inherits default from: "from@example.com" unless you override from: inside the RecipeMailer class. The application mailer should look like the following with the default from address and using the mailer layout:

# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "from@example.com"
  layout "mailer"
end

“mailer” layout is a Rails default layout for email templates. You can find and modify it at app/views/layouts/mailer.html.erb (and app/views/layouts/mailer.text.erb for text emails).

Update the mailer view #

Open app/views/recipe_mailer/share.html.erb and replace the content with the following:

<%# app/views/recipe_mailer/share.html.erb %>
<p><%= @heading %></p>

<h1><%= @recipe.title %></h1>

<p>
  You can view the recipe by clicking the link below:

  <%= link_to "View recipe", recipe_url(@recipe) %>
</p>

You can leave the text version of the email (app/views/recipe_mailer/share.text.erb) as is or update it to match the HTML version. I normally don’t use it so I end up deleting it in production applications.

This is what’s happening in the code above:

  • @heading is the heading of the email you previously set in the mailer action. Guest and signed-in mailer tests assert on that string in subject and body.
  • recipe_url(@recipe) emits a full URL (e.g. https://localhost:3000/recipes/1) required for real emails. You don’t use recipe_path here because it doesn’t include the domain name, for example it only returns /recipes/1.

Run the mailer test again:

bin/rails test test/mailers/recipe_mailer_test.rb

You want 0 failures and 0 errors. That is green for the mailer layer.

RecipeMailer#share is green in isolation: subject, heading, body, and recipient match what the mailer test asserts.

The email content is proven before the share button exists.

You wrote the mailer test first, watched it fail, then filled in the mailer and HTML template until assertions passed. If that habit is sticking, consider supporting this guide by sponsoring it or buying me a drink.

Green: Wire the share form and fix integration test #

The mailer test proved the content and message of the email. You will now wire the share feature to fix the integration test. The share feature will be implemented as follows:

  • Member route on recipes to handle the share action (post :share).
  • Controller action accessible to everyone in the app that sends the email to the recipient.
  • View partial that holds the share form inside a HTML native <dialog>.

Routes #

Append the following lines to config/routes.rb just below resources :steps, only: :destroy:

# config/routes.rb
resources :recipes do
  # ... existing code ...
  resources :steps, only: :destroy

  member { post :share }
end

This will add recipes/:id/share route to the app for sharing the recipe by email.

Controller: Add the share action #

Update the RecipesController at app/controllers/recipes_controller.rb to include the share action:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  # ... existing code ...

  def share
    recipient = params[:recipe][:recipient_email]
    sender = current_user&.email_address

    RecipeMailer.share(@recipe, recipient, sender).deliver_now
    redirect_to @recipe, notice: "Recipe shared with #{recipient}."
  end

  private

  # ... existing code ...
end

This is what’s happening in the code above:

  • params[:recipe][:recipient_email] is the address of the recipient sent from the share form. We are not using strong params here because it’s just a single field and the one we know.
  • Email address of the sender can be nil for guests so we are using &. (safe navigation operator) to avoid NoMethodError. Signed-in users pass their address into the subject.
  • deliver_now sends the email to the recipient before redirecting the user back to the recipe show page.

Anyone in the app should be able to share a recipe by email. To enable the public access you need to update the allow_unauthenticated_access to include share. And while you are at it, make sure to also add the share action to the set_recipe list so the recipe instance is loaded for the share action:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  allow_unauthenticated_access only: %i[index show share]
  before_action :set_recipe, only: %i[show edit update destroy share]

  # ... existing code ...
end

Share dialog partial #

For the view part, there are two ways to approach the share feature:

  1. Create a new page that holds the share form. And submitting the form will send the email to the recipient.
  2. Add a share form inside a modal component using the HTML native <dialog> element and render it directly inside the recipe show page.

In this guide, we will go with the second approach because it’s simpler in terms of implementation and better in terms of the UX perspective. Simple because you don’t need to create a new page, add a new route or controller action for it. Better UX because it makes the share form available right where the user is without navigating to the extra page.

Create a new file with nano app/views/recipes/_share_modal.html.erb and add the following markup for holding the share form inside a HTML native <dialog>:

<%# app/views/recipes/_share_modal.html.erb %>
<button type="button" command="show-modal" commandfor="share-recipe-<%= recipe.id %>">
  Share by email
</button>

<dialog id="share-recipe-<%= recipe.id %>">
  <h2>Share <%= recipe.title %></h2>

  <%= form_with url: share_recipe_path(recipe), scope: :recipe, method: :post do |form| %>
    <fieldset class="fieldset">
      <%= form.label :recipient_email, "Friend's email", class: "label" %>
      <%= form.email_field :recipient_email, class: "input input-bordered w-full", required: true %>
    </fieldset>

    <%= form.submit "Send recipe", class: "btn btn-primary" %>
  <% end %>

  <form method="dialog">
    <button value="cancel">Cancel</button>
  </form>
</dialog>

Then render it inside the recipe show page at app/views/recipes/show.html.erb by adding the following line just below <%= render @recipe, for_show: true %>:

<%# app/views/recipes/show.html.erb %>
<%= render @recipe, for_show: true %>
<%= render "recipes/share_modal", recipe: @recipe %>

<%# ... existing code ... %>

This is what’s happening in the markup above:

  • command="show-modal" and commandfor tell the browser to open that <dialog> when the button is clicked. commandfor targets the dialog to open by its ID. Both of these attributes come from the native HTML dialog API so you don’t need any Stimulus controller or onclick handler for the dynamic modal (open/close).
  • form_with ... scope: :recipe posts the recipient email field inside the “recipe” attribute e.g. recipe[recipient_email], which matches params[:recipe][:recipient_email] in the controller.
  • A nested <form method="dialog"> near the end of the file with the Cancel button closes the dialog. That also comes from the native HTML dialog.

Green: Run both mailer and integration tests #

Run the mailer and integration tests for the recipe share feature together. You want 0 failures and 0 errors:

bin/rails test test/mailers/recipe_mailer_test.rb test/integration/recipes_integration_test.rb

System test: a small smoke test for the share feature #

Integration test already proved the feature for sharing the recipe with a friend works end to end. One small smoke test further strengthens the test suite by ensuring the feature works in a real browser.

Open test/system/recipes_test.rb and append the following scenario to the end of the file:

# test/system/recipes_test.rb
  # Actor: guest (browser)
  # Starting point: recipes(:pancakes) on show
  # Action: open share dialog, fill friend's email, submit
  # Expected outcome: redirect to recipe show page with a flash message about sharing and a recipe title
  # test "shares a recipe" do
  # end

Replace the body of test "shares a recipe" with the following:

# test/system/recipes_test.rb
  test "shares a recipe" do
    recipe = recipes(:pancakes)

    visit recipe_url(recipe)
    click_on "Share by email"
    fill_in "Friend's email", with: "friend@example.com"
    click_on "Send recipe"

    assert_text "Recipe shared with friend@example.com"
    assert_text recipe.title
  end

This is what’s happening in the code above:

  • click_on "Share by email" fires the native command="show-modal" button so the dialog opens in the browser.
  • fill_in "Friend's email" uses the label from the share dialog partial to fill the recipient email field.
  • For the outcome, you assert the flash message about sharing the recipe and the title of the recipe is still on the show page.

Run the system test, you want 0 failures and 0 errors:

bin/rails test test/system/recipes_test.rb -i test_shares_a_recipe

Password reset mailer test #

Chapter 12 already proved that a known email enqueues PasswordsMailer#reset with assert_enqueued_email_with. We postponed the mailer test for the password reset feature in that chapter so all mailer tests could be added at once.

Create a new file with nano test/mailers/passwords_mailer_test.rb and add the following:

# test/mailers/passwords_mailer_test.rb
require "test_helper"

class PasswordsMailerTest < ActionMailer::TestCase
  include Rails.application.routes.url_helpers

  test "reset a password by email" do
    freeze_time do
      user = users(:alice)
      email = PasswordsMailer.reset(user)

      assert_emails 1 do
        email.deliver_now
      end

      assert_equal [user.email_address], email.to
      assert_equal ["from@example.com"], email.from
      assert_equal "Reset your password", email.subject
      assert_match edit_password_path(user.password_reset_token), email.body.encoded
    end
  end
end

Here is what’s happening in the code above:

  • freeze_time freezes the time to the current time so the test is deterministic and doesn’t depend on the real time. This is needed because the password reset token is based on the current time due to which test will otherwise fail because edit_password_path(user.password_reset_token) won’t match with the one in the email body.
  • Other assertions are mostly similar to the ones in the previous mailer test and assert email delivery plus the content of the email.

Run the mailer tests, you want 0 failures and 0 errors:

bin/rails test test/mailers/passwords_mailer_test.rb

Email delivery in the test environment #

Rails uses the :test delivery method by default in the test environment for email delivery. If you open config/environments/test.rb you will see the following line:

# config/environments/test.rb
config.action_mailer.delivery_method = :test

With the :test delivery method, emails do not leave your machine. Instead, they accumulate in ActionMailer::Base.deliveries when something calls deliver_now (or deliver_later) and assert_emails asserts on that count. This is why all mailer tests passed from the beginning without any additional configuration (after wiring the implementation).

Preview emails in the browser #

Your mailer tests prove recipient, subject, and body strings but they do not tell you if the design side of the email is good or not. For example, whether the heading wraps oddly, whether the link looks like a button or just a plain URL, or whether the HTML feels cramped once you stare at it. That is what mailer previews are for; you can view the design of the email in the browser without needing to send them to a real email address.

After you define mailer specific files at test/mailers/previews/, you can visit the special URL /rails/mailers in the browser and see the preview of the email. Rails can render any mailer action in the browser while you work in development without any other additional gem setup; this feature is provided by default and called “Mailer Previews”.

This is different from letter_opener, which you wire later in this chapter. letter_opener opens the email after a real delivery from the running app (catches the email after it is sent from the Rails app but before the real delivery happens). But the mailer preview just builds the mail object directly from a small Ruby class under test/mailers/previews/ and displays the built email in the browser. I like to use both of them in projects I work on.

Preview the share recipe email #

Create a new preview file with nano test/mailers/previews/recipe_mailer_preview.rb and add the following:

# Preview all emails at http://localhost:3000/rails/mailers/recipe_mailer
class RecipeMailerPreview < ActionMailer::Preview
  # Preview this email at http://localhost:3000/rails/mailers/recipe_mailer/share
  def share
    recipe = Recipe.new(id: 1, title: "Fluffy pancakes")
    sender = "alice@example.com"

    RecipeMailer.share(recipe, "friend@example.com", sender)
  end

  # Preview this email at http://localhost:3000/rails/mailers/recipe_mailer/share_by_guest
  def share_by_guest
    recipe = Recipe.new(id: 1, title: "Fluffy pancakes")

    RecipeMailer.share(recipe, "friend@example.com")
  end
end

Each public method on the preview class becomes one preview. The method must return a mail object e.g. RecipeMailer.share(...). You can add as many preview actions as you want based on the different edge cases you want to view in the browser. Here, you have added two: one for a signed-in user and another for a guest. Normally, you would only have one preview action per mailer.

The “share” preview action is only building a Recipe object with the required attributes. Recipe.new(id: 1, title: "Fluffy pancakes") is enough for this preview since the view only needs title, recipe_url. We are also passing an id because recipe_url needs it to build the show page URL /recipes/:id. The best practice when building a record required for the preview is to skip optional attributes you are not rendering. That’s the same habit as a focused test that only declares what’s needed for that test to pass.

For the record to send to the actual mailer, you could also find and load them from the development database but I generally avoid that because it can break the preview if the record is not found. For example, a fresh app, a wiped DB, or a deleted pancakes row would break the preview for the share recipe by email.

To view these previews in the browser, follow the steps below:

  1. Start the app if it is not already running (bin/dev).
  2. Open http://localhost:3000/rails/mailers in the browser.
  3. Click RecipeMailer → share, or go straight to http://localhost:3000/rails/mailers/recipe_mailer/share.
  4. Click RecipeMailer → share_by_guest, or go straight to http://localhost:3000/rails/mailers/recipe_mailer/share_by_guest.

Preview the password reset email #

For the password reset, you don’t need to create a new preview file as it has already been created by the authentication generator. You can just open the existing preview file at test/mailers/previews/passwords_mailer_preview.rb and replace the content with the following:

# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
  # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
  def reset
    user =
      User.new(
        email_address: "alice@example.com",
        password: "password",
        password_confirmation: "password"
      )

    PasswordsMailer.reset(user)
  end
end

This is what’s happening in the code above:

  • First the user record is built with the required attributes similar to how you built the recipe record for the share preview.
  • PasswordsMailer.reset(user) builds the mail object that will be used by the mailer preview to display the email in the browser.

To preview the password reset email, follow the steps below:

  1. Start the app if it is not already running (bin/dev).
  2. Open http://localhost:3000/rails/mailers in the browser.
  3. Click PasswordsMailer → reset, or go straight to http://localhost:3000/rails/mailers/passwords_mailer/reset.

When a preview earns its keep #

Reach for a preview when you care about how the email looks:

  • You are editing HTML or CSS in app/views/.
  • You want to check spacing, heading hierarchy, or a long title.
  • You want to see how the email looks for both HTML (.html.erb) and Text (.text.erb) versions.
  • You want to show the design of the email to a teammate without wiring SMTP.

Just keep it in mind that the preview is a development convenience and not an automated way to ensure email is being sent correctly. The tests are still the source of truth for that.

Open emails in the browser with letter_opener #

Mailer previews render a template in a special page inside the Rails app without going through the actual form submission. This doesn’t test or let you know if the email is actually delivered or not. That’s where letter_opener comes in; it opens the email in a new browser tab after it is sent from the Rails app. This will ensure the email leaves your app, though it still doesn’t tell you if the email was received by the recipient or not. You will need to use a real email provider and SMTP configurations to know that for sure.

We will not discuss or configure the real delivery of the email here (that will be a separate topic) but you can ensure the email leaves your app by configuring the letter_opener gem.

Add the gem to the development group in your Gemfile just below the web-console gem:

# Gemfile

# ... existing gems ...

group :development do
  gem "web-console"
  gem "letter_opener"
end

# ... existing gems ...

Then install it with the following command:

bundle install

Finally, append the following configuration to the end of config/environments/development.rb so the delivery_method for the development environment points to letter_opener and emails are intercepted by the gem:

# config/environments/development.rb
Rails.application.configure do
  # ... existing configurations ...

  # Open the email in the browser instead of sending it to a real mailbox.
  config.action_mailer.delivery_method = :letter_opener
end

That’s it! Now if you send an email from the app, it will be opened in a browser tab instead of being delivered to a real mailbox.

Share a recipe in the browser #

Time to see the letter_opener in action!

To view the email for the share recipe, follow the steps below:

  1. Run the Rails server if it is not already running (bin/dev).
  2. Open the recipe list page at http://localhost:3000/recipes and click on the “Fluffy pancakes” recipe to open the show page (you don’t need to sign in).
  3. Click Share by email button just below the “Destroy this recipe” button, enter friend@example.com in the dialog, submit Send recipe.
  4. Confirm you land back on the show page with a success message and a new browser tab with recipe shared email is opened by letter_opener.

Reset a password in the browser #

Steps are similar to the share recipe for the password reset as well:

  1. Open the sign-in page (/session/new).
  2. Click Forgot password?.
  3. Enter alice@example.com and click Email reset instructions.
  4. Confirm you land on the sign-in page with the success flash message.
  5. letter_opener will open a new browser tab with the password reset email. In that tab, confirm the subject and body mention a password reset, then click the reset link in the email.
  6. On the edit password page, enter a new password twice (for example new-password) and click Save.
  7. Sign in as Alice with alice@example.com and that new password. Confirm you see Sign out.

If you want Alice’s fixture password back for later manual checks, reload development fixtures back again (bin/rails db:fixtures:load) or set the password again through the same reset flow.

Commit your work #

Run the full suite before you commit:

bin/rails test:all

You want 0 failures and 0 errors across model, mailer, integration, and system tests. Then commit your changes:

git add .
git commit -m "Add recipe share mailer, previews, and password reset mailer coverage"

Small commits make it easier to roll back the mailer, the route, or the integration test independently if something regresses later.

What is next #

Share still calls deliver_now, so the browser waits until the mailer finishes before the redirect returns. This is fine for the learning purpose of this chapter but in production applications, this can cause a significant delay in the response time especially when delivery is slow or you add more outbound work.

Chapter 15 switches share recipe email to deliver_later so Active Job queues the mailer and the request can return right away. Next chapter will also teach you how to write a custom job by exporting every recipe in the app to a file.

Continue to Testing background jobs.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Companies can support the guide by sponsoring a chapter (one-time payment) or becoming a Patron with a monthly subscription. This funds new chapters, Rails version updates, and more real-world examples.

If you just want to chip in as a reader, you can also buy me a drink as a thanks.

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