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' }
endWith :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
endUnit 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
endTesting 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
endOr 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
endTesting 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
endTesting 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
endRSpec.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
endMinitest 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
endassert_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
endView 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
endIn 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
endSummary
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.