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
# => "[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-extractedgenerate_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"
endNo 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
endEvery 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")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_inboxOne 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
endIn 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
endWhat 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 zerodropThen take your most important skipped email spec — everyone has one — and make it real.