DEV Community

zerodrop
zerodrop

Posted on • Originally published at zerodrop.dev

RSpec Email Testing Without MailCatcher

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
# => "swift-x7k29ab@zerodrop-sandbox.online"

# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Magic links

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")
Enter fullscreen mode Exit fullscreen mode

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: "noreply@yourapp.com",
    subject: "Verify",
    has_otp: true
  )
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

It also trades one dependency for two. MailCatcher runs locally and fails in ways you can fix yourself; this approach depends on your email provider and on ZeroDrop, and on the round-trip completing inside your timeout. That's a real cost. The reason I make the trade is that a local SMTP daemon only proves delivery to localhost — it can't tell you whether your provider actually accepted and sent the message, so a misconfigured API key in CI still passes. If your provider is stable and you mainly care about template rendering and token round-trips, the local daemon is the simpler dependency and worth keeping.

Try it

gem install zerodrop
Enter fullscreen mode Exit fullscreen mode

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


ZeroDrop is free to use — no signup, no API key, works in CI out of the box.

zerodrop.dev · zerodrop-mcp · SDK · docs

Top comments (3)

Collapse
 
svyatov profile image
Leonid Svyatov

@zerodrop this moves the dependency rather than removing it. Every spec now needs my provider and your service both up, with the message ready inside 15 seconds. MailCatcher breaks in ways I can fix myself. Worth adding to the caveats.

Collapse
 
zerodrop profile image
zerodrop

Fair point, and I should have called it out more explicitly in the article.

You're absolutely trading a local dependency for two network dependencies (your email provider and ZeroDrop), so the failure modes change. If either is unavailable, your integration tests can fail for reasons unrelated to your application. That's a real trade-off, and it's worth mentioning.

The reason I personally made that trade is that I wanted my tests to exercise the same delivery path production uses. With MailCatcher, I found I was validating SMTP delivery to localhost rather than whether my actual provider accepted and delivered the message. Misconfigured provider credentials or provider-specific issues could still make it to production while the test suite stayed green.

That said, if your goal is mainly testing template rendering or token round-trips and you're happy with local SMTP semantics, MailCatcher is still a perfectly reasonable choice.

I'll update the article to make that trade-off clearer. Thanks for pointing it out.

Collapse
 
svyatov profile image
Leonid Svyatov

Right, those are two different tests: does the mail render, and did the provider accept it. MailCatcher only covers the first. I had them as one thing. Thanks for spelling it out 👍️