← Writing

RSpec Email Testing Without MailCatcher

·5 min read

Rails developers test email one of three ways, and each verifies something different.

ActionMailer::Base.deliveries — the test adapter collects would-be emails in an array, and you assert against it. Fast, built in, and it verifies exactly one thing: that your code called the mailer. Whether the SMTP credentials work, whether the template renders, whether the OTP in the email matches the one in the database — the deliveries array has no opinion. It is a mock with better branding.

MailCatcher — a real local SMTP server. Your app sends actual mail to 127.0.0.1:1025, and you inspect it through a web UI on 1080 or its HTTP API. This is honest testing: real SMTP, real MIME parsing, real templates. The cost is a daemon. Locally that's mailcatcher running in a forgotten terminal tab; in CI it's a service block, a port mapping, and a health check before your suite can start. And when you want the OTP out of a caught message, you're parsing HTML with a regex.

Nothing — the most popular option. The signup flow gets a skip and a comment that says # TODO: test email verification.

There's a fourth option: real email, no daemon, no regex.

The shape of it

Instead of catching mail locally, your app sends real email — through your actual provider, with your actual credentials — to a disposable inbox that exists at the edge. The test then asks for what arrived, and gets the OTP and magic link back as fields, already extracted.

require "zerodrop"
 
mail = ZeroDrop::Client.new
inbox = mail.generate_inbox
# => "[email protected]"
 
# Trigger your app's email flow with the inbox address...
 
email = mail.wait_for_latest(inbox, timeout: 15)
 
email.subject    # => "Verify your email"
email.otp        # => "123456" — auto-extracted, no regex
email.magic_link # => "https://..." — auto-extracted

generate_inbox is a local call — no network request, no registration. The address exists the moment your app sends something to it. Messages live for 30 minutes and are then removed by a hard TTL, so there's no cleanup step and nothing to tear down.

Install:

# Gemfile
group :test do
  gem "zerodrop"
end

No API key needed for the free tier.

A full signup test in RSpec

Here's the flow the deliveries array can't test: user signs up, a real email goes out through your real provider, and the code in that email actually verifies the account.

RSpec.describe "Signup email verification", type: :system do
  let(:mail) { ZeroDrop::Client.new }
 
  it "verifies the account via emailed OTP" do
    inbox = mail.generate_inbox
 
    visit "/signup"
    fill_in "Email", with: inbox
    fill_in "Password", with: "TestPassword123!"
    click_button "Create account"
 
    email = mail.wait_for_latest(inbox, timeout: 15)
    expect(email.otp).not_to be_nil
 
    fill_in "Code", with: email.otp
    click_button "Verify"
 
    expect(page).to have_current_path("/dashboard")
  end
end

Every layer is real: your controller, your mailer, your template, your provider's API, and the token round-trip between the email and the database. If your Resend key is wrong in this environment, this test fails. If the template renders the OTP inside a broken <span>, this test fails. The deliveries array passes in both cases.

Note there's no config.action_mailer.delivery_method = :test anywhere. That's the point — this test wants delivery to actually happen.

Same pattern, and the link comes out as a field you can visit directly:

email = mail.wait_for_latest(
  inbox,
  timeout: 15,
  filter: ZeroDrop::Filter.new(has_magic_link: true)
)
 
visit email.magic_link
expect(page).to have_current_path("/dashboard")

has_magic_link: true means the wait ignores anything that arrives without a link — a welcome email, a marketing footer — and resolves only when the message you actually want lands.

When a flow sends more than one email

Signup flows rarely send exactly one message. A welcome email and a verification email often race each other, and "grab the latest" becomes a coin flip. Filters make the wait deterministic:

email = mail.wait_for_latest(
  inbox,
  timeout: 15,
  filter: ZeroDrop::Filter.new(
    from: "[email protected]",
    subject: "Verify",
    has_otp: true
  )
)

This resolves only on a message from your sender, with "Verify" in the subject, that contains an extractable OTP. The welcome email can arrive first, second, or not at all — the test doesn't care.

Parallel suites

If you run under parallel_tests, flatware, or turbo_tests, inbox collisions are the classic source of flake: two workers read the same mailbox and consume each other's messages. Here that can't happen, because each test mints its own address:

# Each call returns a unique inbox — local, no network request
inbox = mail.generate_inbox

One inbox per test is the contract. There's no shared mailbox to collide in, so isolation holds by construction rather than by discipline.

Failure modes

Three typed errors cover what can actually go wrong:

begin
  email = mail.wait_for_latest(inbox, timeout: 15)
rescue ZeroDrop::TimeoutError
  # No email arrived — check your app is sending correctly
rescue ZeroDrop::AuthError
  # Invalid API key
rescue ZeroDrop::NetworkError => e
  # Transport failure — e.message includes a status page link
end

In CI you usually don't rescue at all — a TimeoutError failing the spec is the correct outcome, because it means your app didn't send the email. That's a real bug surfacing, not flake.

The default poll interval is 2 seconds; wait_for_latest(inbox, timeout: 10, poll_interval: 2) are the knobs if you need them.

Minitest, for completeness

Nothing here is RSpec-specific:

require "minitest/autorun"
require "zerodrop"
 
class SignupTest < Minitest::Test
  def test_email_verification
    mail = ZeroDrop::Client.new
    inbox = mail.generate_inbox
 
    # Trigger signup with inbox...
 
    email = mail.wait_for_latest(inbox, timeout: 15)
    refute_nil email.otp
    assert_match(/\A\d{6}\z/, email.otp)
  end
end

What this doesn't replace

Honesty section. The deliveries array still has a job: unit tests of mailer logic — right recipient, right locale, right conditional content — are faster and better there, and you should keep them. This approach is for the system-level question the deliveries array can't answer: does the email flow work, end to end, in this environment?

It also won't tell you about deliverability — spam scoring, DKIM alignment, inbox placement at Gmail. Different problem, different tools.

And the free tier uses a shared sandbox domain with a 30-minute TTL. Fine for test traffic; wrong for anything resembling real user data.

Try it

gem install zerodrop

Then take your most important skipped email spec — everyone has one — and make it real.