Testing Action Mailer in Rails: Unit, Integration, and Preview

Testing Action Mailer in Rails: Unit, Integration, and Preview

Email is one of the most undertested parts of Rails applications. Bugs in welcome emails, password resets, and notifications often slip through because developers don't know how to write email tests efficiently. Rails makes it straightforward — the test mailbox captures every email sent during tests, and both RSpec and Minitest have built-in helpers for asserting on email content.

Test Configuration

Configure Action Mailer to use the test delivery method (prevents actually sending emails):

# config/environments/test.rb
Rails.application.configure do
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
end

With :test delivery, emails accumulate in ActionMailer::Base.deliveries during the test run. Each test should clear this array:

# test/test_helper.rb (Minitest)
class ActiveSupport::TestCase
  setup { ActionMailer::Base.deliveries.clear }
end

# spec/rails_helper.rb (RSpec)
RSpec.configure do |config|
  config.before(:each) do
    ActionMailer::Base.deliveries.clear
  end
end

Unit Testing Mailer Methods

Test the mailer class directly without triggering delivery:

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def welcome_email(user)
    @user = user
    @login_url = login_url

    mail(
      to: user.email,
      subject: "Welcome to MyApp, #{user.first_name}!"
    )
  end

  def password_reset(user, token)
    @user = user
    @reset_url = password_reset_url(token: token)
    @expires_at = 2.hours.from_now

    mail(
      to: user.email,
      subject: "Reset your MyApp password"
    )
  end
end
# spec/mailers/user_mailer_spec.rb
RSpec.describe UserMailer do
  describe "#welcome_email" do
    let(:user) { create(:user, email: "alice@example.com", first_name: "Alice") }
    let(:mail) { UserMailer.welcome_email(user) }

    it "renders the correct recipient" do
      expect(mail.to).to eq(["alice@example.com"])
    end

    it "renders the subject with user's name" do
      expect(mail.subject).to eq("Welcome to MyApp, Alice!")
    end

    it "includes the login URL in the body" do
      expect(mail.body.encoded).to include("http://example.com")
    end

    it "addresses the user by name" do
      expect(mail.body.encoded).to include("Alice")
    end

    it "sends from the default from address" do
      expect(mail.from).to eq(["noreply@myapp.com"])
    end
  end

  describe "#password_reset" do
    let(:user) { create(:user) }
    let(:token) { "reset-token-abc123" }
    let(:mail) { UserMailer.password_reset(user, token) }

    it "includes the reset URL" do
      expect(mail.body.encoded).to include("reset-token-abc123")
    end

    it "mentions the expiry time" do
      expect(mail.body.encoded).to include("2 hours")
    end
  end
end

Testing Delivery with deliver_now

Use deliver_now to trigger delivery and assert on ActionMailer::Base.deliveries:

RSpec.describe "User registration" do
  it "sends a welcome email after signup" do
    expect {
      post "/users", params: {
        user: { email: "new@example.com", password: "password" }
      }
    }.to change(ActionMailer::Base.deliveries, :count).by(1)

    email = ActionMailer::Base.deliveries.last
    expect(email.to).to include("new@example.com")
    expect(email.subject).to include("Welcome")
  end
end

Or use RSpec's have_enqueued_mail matcher (for deliver_later):

# With ActiveJob integration
RSpec.describe "Password reset" do
  it "enqueues a reset email" do
    user = create(:user)

    expect {
      post "/password/reset", params: { email: user.email }
    }.to have_enqueued_mail(UserMailer, :password_reset).with(user, anything)
  end
end

Testing HTML and Text Parts

Most mailers send both HTML and plain text. Test both:

describe "#welcome_email" do
  let(:mail) { UserMailer.welcome_email(user) }

  it "sends a multipart email" do
    expect(mail.content_type).to start_with("multipart/alternative")
  end

  it "includes HTML part" do
    html_part = mail.html_part.body.decoded
    expect(html_part).to include("<h1>")
    expect(html_part).to include(user.first_name)
  end

  it "includes text part" do
    text_part = mail.text_part.body.decoded
    expect(text_part).not_to include("<h1>")  # no HTML tags in text
    expect(text_part).to include(user.first_name)
  end
end

Testing Attachments

# app/mailers/report_mailer.rb
class ReportMailer < ApplicationMailer
  def monthly_report(user, report_path)
    @user = user

    attachments["monthly-report.pdf"] = File.read(report_path)

    mail(to: user.email, subject: "Your Monthly Report")
  end
end
RSpec.describe ReportMailer do
  describe "#monthly_report" do
    let(:user) { create(:user) }
    let(:report_path) { Rails.root.join("spec/fixtures/sample-report.pdf") }
    let(:mail) { ReportMailer.monthly_report(user, report_path) }

    it "attaches the report" do
      expect(mail.attachments.count).to eq(1)
    end

    it "names the attachment correctly" do
      expect(mail.attachments.first.filename).to eq("monthly-report.pdf")
    end

    it "sets the correct MIME type" do
      expect(mail.attachments.first.mime_type).to eq("application/pdf")
    end
  end
end

Minitest Style

# test/mailers/user_mailer_test.rb
class UserMailerTest < ActionMailer::TestCase
  test "welcome email" do
    user = users(:alice)
    mail = UserMailer.welcome_email(user)

    assert_equal ["alice@example.com"], mail.to
    assert_equal "Welcome to MyApp, Alice!", mail.subject
    assert_match "Alice", mail.body.encoded
    assert_match "login", mail.body.encoded
  end

  test "sends welcome email on create" do
    assert_emails 1 do
      post users_url, params: {
        user: { email: "new@test.com", password: "password" }
      }
    end
  end

  test "no email for invalid signup" do
    assert_no_emails do
      post users_url, params: {
        user: { email: "invalid", password: "" }
      }
    end
  end
end

assert_emails and assert_no_emails are the Minitest-specific wrappers that check ActionMailer::Base.deliveries.count.

Mailer Previews

Mailer previews let you view emails in the browser without sending them. They live in test/mailers/previews/:

# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome_email
    user = User.first || FactoryBot.build(:user)
    UserMailer.welcome_email(user)
  end

  def password_reset
    user = User.first || FactoryBot.build(:user)
    UserMailer.password_reset(user, "preview-token-123")
  end

  def monthly_report
    user = User.first || FactoryBot.build(:user)
    report_path = Rails.root.join("spec/fixtures/sample-report.pdf")
    ReportMailer.monthly_report(user, report_path)
  end
end

View at http://localhost:3000/rails/mailers/user_mailer in development. This is how you visually validate email templates without needing an email client.

Testing Internationalization

describe "#welcome_email with locale" do
  it "sends email in user's locale" do
    user = create(:user, locale: "es")
    mail = UserMailer.welcome_email(user)

    expect(mail.subject).to eq("Bienvenido a MyApp, #{user.first_name}!")
    expect(mail.body.encoded).to include("Hola")
  end
end

In the mailer:

def welcome_email(user)
  @user = user

  I18n.with_locale(user.locale) do
    mail(to: user.email, subject: t("mailer.welcome.subject", name: user.first_name))
  end
end

Summary

Action Mailer tests fall into two categories: unit tests that call the mailer method directly and assert on the mail object's attributes, and integration tests that trigger controller actions and verify delivery happened. Unit tests are fast and deterministic — they don't depend on a full request cycle. Integration tests verify the wiring — that the right events actually trigger email sending. Both are needed. Mailer previews complement tests by enabling visual validation of templates, catching layout and formatting issues that assertions can't catch.

Read more

Start now free