Mailer testing and previews for Ruby on Rails developers
Email delivery is critical to your application's user experience, yet testing it remains one of the most neglected areas in Rails projects. Getting email wrong means lost password resets, failed transactions, and frustrated users—often discovered in production. In this article, we'll walk through mailer testing and previews for Ruby on Rails developers, covering the tools and patterns that give you real confidence in your email implementations.
Why mailer testing matters
Emails sit at an interesting boundary in your application. They're asynchronous, they reach users outside your app, and they're often triggered by critical workflows. Unlike a controller response, you can't just inspect the HTTP status. A mailer can silently fail, send the wrong content, or include malformed links. Without deliberate testing, you won't know until customers complain.
Rails gives us solid built-in tools through ActionMailer::TestCase and mail previews. These aren't fancy, but they're reliable and fast enough for test-driven development. Let's cover what actually works in production.
ActionMailer::TestCase fundamentals
The foundation of mailer testing is ActionMailer::TestCase, which provides a test context and helpers specifically for emails. When you generate a mailer, Rails sets this up automatically:
class WelcomeMailerTest < ActionMailer::TestCase
test "send welcome email to new user" do
user = User.create!(email: "alice@example.com", name: "Alice")
mail = WelcomeMailer.with(user: user).welcome
assert_equal ["alice@example.com"], mail.to
assert_equal "Welcome to our platform, Alice", mail.subject
assert_includes mail.body.encoded, "get started"
end
endThe key here is that ActionMailer::TestCase handles mail interception. It doesn't actually send emails during tests; instead, it captures them for inspection. The mail variable is a Message object with all the properties you'd expect: to, from, subject, body, and more.
Notice we're using mail.body.encoded to access the rendered content. This is important because mail.body returns a String object in Rails, and .encoded ensures you're testing against the actual output that will be sent. Always use .encoded when asserting content.
Using assert_emails for count verification
One of the most powerful assertions in mailer testing is assert_emails. This counts how many emails were sent (or received by the interceptor) during a block, and it's invaluable for integration tests and background job tests:
test "creates user and sends welcome email" do
assert_emails 1 do
User.create!(email: "bob@example.com", name: "Bob")
end
end
test "sends notification to all admins" do
admin1 = User.create!(email: "admin1@example.com", role: "admin")
admin2 = User.create!(email: "admin2@example.com", role: "admin")
assert_emails 2 do
NotificationMailer.alert(message: "Server issue").deliver_now
end
endassert_emails is especially useful for testing that your background jobs actually send mail. If you're using Active Job with Sidekiq or similar, you can test the integration:
test "background job sends digest email" do
user = User.create!(email: "digest@example.com")
10.times { user.notifications.create!(message: "Event #{_1}") }
assert_emails 1 do
DigestMailer.with(user: user).send_digest.deliver_now
end
endThe inverse, assert_no_emails, is also valuable for confirming that something shouldn't send an email:
test "does not email unsubscribed users" do
user = User.create!(email: "noemail@example.com", subscribed: false)
assert_no_emails do
NotificationMailer.with(user: user).new_post.deliver_now
end
endMail previews for design review
Mailer tests verify logic, but they don't show you how an email actually looks. That's where previews come in. Rails provides a built-in preview system that renders your emails in a web interface without sending them.
When you generate a mailer, Rails also creates a preview class:
class WelcomeMailerPreview < ActionMailer::Preview
def welcome
user = User.first || User.create!(email: "preview@example.com", name: "Preview User")
WelcomeMailer.with(user: user).welcome
end
def reminder
user = User.first || User.create!(email: "preview@example.com", name: "Preview User")
WelcomeMailer.with(user: user, days_remaining: 3).reminder
end
endWith this in place, navigate to http://localhost:3000/rails/mailers in development. You'll see a list of all preview methods and can click through to see how each email renders. This is where you catch design issues, broken links, and rendering problems without waiting for real emails to arrive.
The critical pattern here is providing realistic seed data. Don't use placeholder names or truncated content. Create test users and pass the data your mailer actually receives in production. This catches issues like long email addresses breaking layouts or multi-line addresses rendering incorrectly.
Testing both HTML and text parts
Most mailers send both HTML and plain-text versions. The multipart structure means you need to test both parts, especially for critical content like account changes or password resets:
test "password reset email includes link in both formats" do
user = User.create!(email: "user@example.com")
reset_token = user.generate_reset_token
mail = PasswordMailer.with(user: user, token: reset_token).reset_instructions
# Test HTML part
assert_includes mail.body.encoded, reset_url(token: reset_token)
assert_includes mail.body.encoded, "<a></a>When a mailer is multipart, Rails stores different content types in mail.parts. You can iterate through them to test each format independently. This matters because plain-text rendering can fail in ways HTML doesn't—missing line breaks, malformed URLs without angle brackets, or escaped characters.
Here's a cleaner pattern using a helper method:
class ActionMailer::TestCase
private
def text_part(mail)
mail.parts.find { |part| part.content_type.match?(/text\/plain/) }
end
def html_part(mail)
mail.parts.find { |part| part.content_type.match?(/text\/html/) }
end
end
test "order confirmation is readable in both formats" do
order = Order.create!(user: User.create!(email: "buyer@example.com"), amount: 99.99)
mail = OrderMailer.with(order: order).confirmation
assert_includes html_part(mail).body.encoded, "$99.99"
assert_includes text_part(mail).body.encoded, "$99.99"
endReal-world scenario: Testing a notification system
Let's walk through testing a more complex scenario. Imagine a notification mailer that sends digests to users, but respects their preferences:
class NotificationDigestMailerTest < ActionMailer::TestCase
setup do
@user = User.create!(email: "digest@example.com")
end
test "sends digest with all notification types when opted in" do
@user.update!(digest_frequency: "daily", receive_all: true)
5.times { @user.notifications.create!(notification_type: "comment") }
3.times { @user.notifications.create!(notification_type: "like") }
assert_emails 1 do
NotificationDigestMailer.with(user: @user).send_digest.deliver_now
end
mail = ActionMailer::Base.deliveries.last
assert_equal [@user.email], mail.to
assert_includes mail.body.encoded, "5 comments"
assert_includes mail.body.encoded, "3 likes"
end
test "does not send digest when opted out" do
@user.update!(digest_frequency: "never")
@user.notifications.create!(notification_type: "comment")
assert_no_emails do
NotificationDigestMailer.with(user: @user).send_digest.deliver_now
end
end
test "respects notification type preferences" do
@user.update!(digest_frequency: "daily", receive_comments: false)
@user.notifications.create!(notification_type: "comment")
@user.notifications.create!(notification_type: "like")
mail = NotificationDigestMailer.with(user: @user).send_digest.message
assert_not_includes mail.body.encoded, "comments"
assert_includes mail.body.encoded, "likes"
end
endThis test suite covers the happy path, opt-out behavior, and preference logic—all the places where emails can silently fail or send incorrect content.
Common mistakes
- Forgetting to call .encoded. Testing
mail.bodydirectly returns a Mail::Body object, not a string. Always usemail.body.encodedto get the actual rendered output. - Not testing with realistic data. Using placeholder users or truncated content in previews misses real-world issues. Use actual database records or detailed seed data.
- Ignoring the text part. Many teams test only HTML emails and miss plain-text rendering failures. Always test both formats if your mailer is multipart.
- Testing with deliver_later in unit tests. In unit tests, use
deliver_nowsoassert_emailscan count immediately. Savedeliver_laterfor integration tests. - Skipping preview development. Previews catch design and rendering issues that tests can't. Use them actively during development, not just before launch.
- Hardcoding test data in mailers. Don't fetch
User.firstin production code. Usewith()to pass data explicitly. This makes testing and previewing easier and safer.
Key takeaways
- Use
ActionMailer::TestCasefor unit tests andassert_emailsto verify counts during integration tests. - Always use
mail.body.encodedwhen asserting email content, notmail.body. - Test both HTML and plain-text parts for critical emails using
mail.parts. - Leverage mail previews at
/rails/mailersto catch design and rendering issues before they reach users. - Provide realistic seed data in previews and tests to surface real-world problems like long email addresses or special characters.
- Test negative cases: verify that emails don't send when they shouldn't, using
assert_no_emails.






Comments
Loading comments…