Rails email tests: deliveries, deliver_later and a real inbox

Updated October 17, 2026 · Zyntra

In the test environment Rails appends every email to ActionMailer::Base.deliveries instead of sending it, and a deliver_later email sits in the job queue until perform_enqueued_jobs runs it. Both checks stop at your app's boundary: test delivery, the link host and staging interceptors with a real inbox against the deployed app.

deliveries, and when it is reset

config/environments/test.rb sets config.action_mailer.delivery_method = :test, so emails are appended to ActionMailer::Base.deliveries. The Rails guide notes that the array is reset automatically only in ActionMailer::TestCase and ActionDispatch::IntegrationTest. System tests inherit from neither, so clear it yourself, or a test reads the email from the test before it.

test/system/signups_test.rb
require "application_system_test_case"

class SignupsTest < ApplicationSystemTestCase
  setup { ActionMailer::Base.deliveries.clear }

  test "sign-up sends a confirmation link" do
    visit new_user_registration_path
    fill_in "Email", with: "[email protected]"
    fill_in "Password", with: "a-long-test-password"
    click_on "Sign up"
    assert_text "confirmation link"

    # Devise sends with deliver_now by default; see deliver_later below.
    mail = ActionMailer::Base.deliveries.last
    assert_equal ["[email protected]"], mail.to
    link = mail.body.encoded[%r{https?://[^"\s]+/confirmation\?[^"\s]+}]
    visit link
    assert_text "confirmed"
  end
end

deliver_later and the test adapter

deliver_later goes through Active Job. The guide is direct about the test queue adapter: it "will not perform jobs until perform_enqueued_jobs is called". So an email sent with deliver_later is only enqueued, deliveries stays empty, and a test that looks for it fails. In mailer and integration tests, wrap the action in perform_enqueued_jobs do … end; assert_emails counts both deliver_now and deliver_later, while assert_enqueued_emails checks only that the job was queued. Since Rails 7.2, all tests respect a queue_adapter set for the test environment, so :inline there runs mail jobs at once in system tests too.

Two staging settings a mailer test never sees

  • Interceptors. The Action Mailer guide's own example registers a SandboxEmailInterceptor on staging that rewrites message.to to one address. With it on, every email from staging goes to that address, and a test inbox never receives anything.
  • default_url_options. Emails need config.action_mailer.default_url_options with a host to build *_url links. Copied from production, it makes staging send production links. Only a real email from staging shows the host.

Against staging with a real inbox

Point Capybara at the deployed staging app and read the email over HTTP. The helper below uses only the standard library.

spec/e2e/signup_spec.rb
require "capybara/rspec"
require "selenium-webdriver"
require "net/http"
require "json"
require "securerandom"

Capybara.run_server = false
Capybara.app_host = ENV.fetch("STAGING_URL")
Capybara.default_driver = :selenium_chrome_headless

def last_email(address)
  uri = URI("https://api.zyntra.app/emails/last")
  uri.query = URI.encode_www_form(email: address)
  req = Net::HTTP::Get.new(uri, "X-API-Key" => ENV.fetch("ZYNTRA_API_KEY"))
  # The server waits up to 30 s for the first email.
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 45) { |http| http.request(req) }
  raise "No email for #{address}: #{res.code}" unless res.is_a?(Net::HTTPSuccess)
  JSON.parse(res.body)
end

RSpec.describe "Sign-up on staging", type: :feature do
  it "sends a confirmation link to the staging host" do
    address = "#{ENV.fetch("ZYNTRA_TEAM_ID")}.rails-#{SecureRandom.hex(4)}@zyntramail.com"

    visit "/users/sign_up"
    fill_in "Email", with: address
    fill_in "Password", with: "a-long-test-password"
    click_on "Sign up"

    links = last_email(address)["links"].map { |l| l["url"] }.grep(%r{/confirmation\?})
    expect(links.size).to eq(1)
    expect(links.first).to start_with(ENV.fetch("STAGING_URL"))

    visit links.first
    expect(page).to have_text("confirmed")
  end
end

On staging, deliver_later with a queue backend such as Sidekiq or Solid Queue needs a running worker. If the worker is down, the email is never sent, and this test is the one that notices.

Questions

Should staging use an interceptor at all?
It protects real people from staging email, which matters when staging has copies of production data. Make it skip your test domain instead of removing it, so test addresses still get their mail.