Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 097 / 100

Mailer testing and previews for Ruby on Rails developers

Confidence in emails.

Expona Admin7 min read

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:

ruby
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
end

The 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:

ruby
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
end

assert_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:

ruby
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
end

The inverse, assert_no_emails, is also valuable for confirming that something shouldn't send an email:

ruby
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
end

Mail 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:

ruby
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
end

With 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:

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

ruby
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"
end

Real-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:

ruby
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
end

This 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.body directly returns a Mail::Body object, not a string. Always use mail.body.encoded to 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_now so assert_emails can count immediately. Save deliver_later for 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.first in production code. Use with() to pass data explicitly. This makes testing and previewing easier and safer.

Key takeaways

  • Use ActionMailer::TestCase for unit tests and assert_emails to verify counts during integration tests.
  • Always use mail.body.encoded when asserting email content, not mail.body.
  • Test both HTML and plain-text parts for critical emails using mail.parts.
  • Leverage mail previews at /rails/mailers to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles