guides/testing.md is the canonical testing guide for mailglass adopters. Start with the default Fake-adapter path, then opt into the narrower cross-process, Oban, and PubSub lanes only when your test actually needs them.

deliver/2 baseline

The stable deliver/2 story is:

  • Mailglass.Adapters.Fake in config/test.exs
  • import Mailglass.TestAssertions
  • process-local assertions that stay async: true safe by default

Minimal setup:

# config/test.exs
config :mailglass,
  repo: MyApp.Repo,
  adapter: Mailglass.Adapters.Fake
defmodule MyApp.UserMailerTest do
  use ExUnit.Case, async: true
  import Mailglass.TestAssertions

  test "delivers the welcome message" do
    %{email: "user@example.com"}
    |> MyApp.UserMailer.welcome()
    |> Mailglass.deliver()

    assert_mail_sent(subject: "Welcome", to: "user@example.com")
  end
end

For library tests and most adopter integration tests, prefer use Mailglass.MailerCase instead of rebuilding the setup yourself. It checks out the Fake adapter, stamps tenancy, subscribes to delivery events, and keeps the common path honest.

Helper semantics in this baseline are exact:

  • assert_mail_sent/0,1 reads the current test process mailbox.
  • last_mail/0 reads Fake-backed delivery storage and does not consume the process mailbox.
  • wait_for_mail/1 waits up to a timeout for {:mail, %Mailglass.Message{}} to arrive and fails if nothing arrives before the timeout.

Use last_mail/0 when you want to inspect the most recent delivered message without consuming mailbox state. Use wait_for_mail/1 when delivery may arrive slightly later than the assertion site.

deliver_later/2 baseline

The stable deliver_later/2 default also starts with Fake assertions plus Mailglass.MailerCase.

Mailglass.MailerCase sets Application.put_env(:mailglass, :async_adapter_impl, Mailglass.Outbound.AsyncAdapter.Inline) in setup, so the default library test path runs deliver_later/2 synchronously under the calling test's ownership. That keeps the baseline deterministic and avoids optional dependencies.

Typical test:

defmodule MyApp.UserMailerLaterTest do
  use Mailglass.MailerCase, async: true

  alias MyApp.UserMailer

  test "delivers later using the default inline adapter" do
    {:ok, _delivery} =
      %{email: "later@example.com"}
      |> UserMailer.welcome()
      |> Mailglass.deliver_later()

    assert_mail_sent(to: "later@example.com")
  end
end

If your app deliberately exercises cross-process dispatch instead of the inline baseline, keep that as an explicit exception and follow the ownership guidance below.

Optional Oban lanes

Only document and support two Oban lanes:

  • :inline
  • :manual

Both are explicit async: false lanes. Both require the oban_jobs table to exist in the test database.

Mailglass.MailerCase enforces the async: false requirement for any @tag oban: ... test because Oban's testing mode is global process state. test/support/oban_helpers.ex exists to ensure the oban_jobs table is present for these tests.

Use @tag oban: :inline when you want Oban-backed deliver_later/2 to execute the job synchronously.

defmodule MyApp.ObanInlineMailerTest do
  use Mailglass.MailerCase, async: false

  @tag oban: :inline
  test "runs the outbound worker inline" do
    {:ok, _delivery} =
      %{email: "inline@example.com"}
      |> MyApp.UserMailer.welcome()
      |> Mailglass.deliver_later()

    assert_mail_sent(to: "inline@example.com")
  end
end

Use @tag oban: :manual when you need queue assertions such as assert_enqueued/1 or explicit worker execution.

defmodule MyApp.ObanManualMailerTest do
  use Mailglass.MailerCase, async: false
  use Oban.Testing, repo: MyApp.Repo

  @tag oban: :manual
  test "asserts on the queued job" do
    {:ok, _delivery} =
      %{email: "manual@example.com"}
      |> MyApp.UserMailer.welcome()
      |> Mailglass.deliver_later()

    assert_enqueued(worker: Mailglass.Outbound.Worker)
  end
end

If you are not explicitly asserting on Oban behavior, stay on the baseline and avoid introducing the extra lane.

Cross-process and browser ownership

Prefer explicit ownership transfer before any shared/global mode.

Recommended first:

  • Mailglass.Adapters.Fake.allow/2 for the specific process that needs access
  • the normal Ecto SQL sandbox ownership-transfer pattern for browser, LiveView, or worker processes

Example:

test "a spawned process can deliver into this test's Fake bucket" do
  parent = self()

  task =
    Task.async(fn ->
      Mailglass.Adapters.Fake.allow(parent, self())

      %{email: "child@example.com"}
      |> MyApp.UserMailer.welcome()
      |> Mailglass.deliver()
    end)

  Task.await(task)

  assert %Mailglass.Message{} = wait_for_mail(500)
end

Only fall back to shared/global mode when targeted ownership transfer is not a fit. The non-async fallback is setup :set_mailglass_global, which requires async: false.

defmodule MyApp.GlobalMailerTest do
  use Mailglass.MailerCase, async: false

  setup :set_mailglass_global

  test "uses shared/global Fake access as an escape hatch" do
    :ok = Mailglass.Adapters.Fake.set_shared(self())
  end
end

That fallback broadens access for the duration of the test. Keep it narrow, prefer Fake.allow/2, and do not treat shared/global mode as the baseline.

PubSub and webhook assertions

Use PubSub-backed assertions when you are proving delivery state or webhook-driven state transitions, not mailbox delivery.

  • assert_mail_delivered/2
  • assert_mail_bounced/2

These helpers wait for {:delivery_updated, delivery_id, status, meta} PubSub broadcasts. They do not inspect the Fake mailbox or Fake delivery storage.

Mailglass.MailerCase subscribes the test process to the tenant-wide events topic. If you are outside MailerCase, subscribe explicitly before asserting.

test "asserts on a delivered event broadcast" do
  tenant_id = "test-tenant"
  delivery_id = Ecto.UUID.generate()

  Phoenix.PubSub.subscribe(
    Mailglass.PubSub,
    Mailglass.PubSub.Topics.events(tenant_id, delivery_id)
  )

  Phoenix.PubSub.broadcast(
    Mailglass.PubSub,
    Mailglass.PubSub.Topics.events(tenant_id, delivery_id),
    {:delivery_updated, delivery_id, :delivered, %{tenant_id: tenant_id}}
  )

  assert_mail_delivered(delivery_id, 100)
end

Use these assertions for webhook and projection flows. Use assert_mail_sent/1, last_mail/0, and wait_for_mail/1 for message-delivery assertions.

Footguns and strict-CI posture

  • Keep the default path simple: Fake adapter, Mailglass.TestAssertions, and Mailglass.MailerCase before any optional lane.
  • last_mail/0 is storage-backed inspection, not a mailbox receive.
  • wait_for_mail/1 is timeout-based and should be used only when delivery is expected to arrive asynchronously.
  • Prefer Fake.allow/2 plus normal sandbox ownership transfer over shared/global mode.
  • Shared/global mode is the async: false fallback via setup :set_mailglass_global.
  • Oban lanes are intentionally narrow: :inline and :manual only, both async: false, both requiring oban_jobs.
  • PubSub assertions prove delivery-status changes, not mailbox state.

This narrow posture is the merge-blocking CI story: one stable baseline first, then explicit exceptions only when the test really needs them.