---
title: "Testing"
description: "Verify that your application reports errors to Sentry correctly using the SDK's built-in test helpers."
url: https://docs.sentry.io/platforms/elixir/testing/
---

# Testing for Elixir

The testing helpers described on this page require **SDK version 13.0.0 or later** and the [`bypass`](https://hex.pm/packages/bypass) library.

`Sentry.Test` and `Sentry.Test.Assertions` provide an isolated, per-test testing environment so you can verify your application reports errors, transactions, logs, metrics, and check-ins to Sentry — without hitting the real API.

## [Setup](https://docs.sentry.io/platforms/elixir/testing.md#setup)

Add `bypass` as a test dependency in `mix.exs`:

```elixir
defp deps do
  [
    {:sentry, "~> 14.0"},
    {:bypass, "~> 2.0", only: [:test]},
    # ...
  ]
end
```

Turn on test mode in your test configuration. It starts the registry the test helpers depend on and isolates Sentry configuration per test:

```elixir
config :sentry, test_mode: true
```

Call `Sentry.Test.setup_sentry/1` in your ExUnit `setup` block. It opens a local HTTP server scoped to the current test, points Sentry's DSN at it, and wires up event collection:

```elixir
defmodule MyAppWeb.ErrorTrackingTest do
  use ExUnit.Case, async: true

  import Sentry.Test.Assertions

  setup do
    Sentry.Test.setup_sentry()
  end
end
```

Pass any Sentry config options as keyword arguments:

```elixir
setup do
  Sentry.Test.setup_sentry(dedup_events: false, traces_sample_rate: 1.0)
end
```

`setup_sentry/1` returns a map that ExUnit merges into the test context. It contains:

* `:bypass` — the local HTTP server that receives this test's envelopes
* `:telemetry_processor` — the name of this test's telemetry processor
* `:client_report_sender` — the name of this test's client report sender, so counts of discarded events don't leak between tests (SDK 14.0.0 and later)

## [Errors](https://docs.sentry.io/platforms/elixir/testing.md#errors)

Use `assert_sentry_report(:event, criteria)` to assert on captured errors and messages. It fails if any criterion doesn't match or if not exactly one event was captured.

Given a controller that manually reports unrecognized webhook events:

```elixir
def create(conn, %{"type" => type} = params) do
  case Webhooks.dispatch(type, params) do
    :ok ->
      send_resp(conn, 200, "ok")

    {:error, :unknown_type} ->
      Sentry.capture_message("Unrecognized webhook event",
        level: :warning,
        tags: %{"webhook.provider" => conn.assigns.provider, "event.type" => type}
      )
      send_resp(conn, 422, "unrecognized event")
  end
end
```

The test calls the endpoint and asserts on the captured event's level, message, and tags:

```elixir
defmodule MyAppWeb.WebhookControllerTest do
  use MyAppWeb.ConnCase, async: true

  import Sentry.Test.Assertions

  setup do
    Sentry.Test.setup_sentry()
  end

  test "reports unrecognized events to Sentry", %{conn: conn} do
    conn
    |> assign(:provider, "github")
    |> post(~p"/webhooks", %{"type" => "unknown.event", "data" => %{}})

    assert_sentry_report(:event,
      level: :warning,
      message: %{formatted: "Unrecognized webhook event"},
      tags: %{"webhook.provider" => "github", "event.type" => "unknown.event"}
    )
  end
end
```

## [Transactions](https://docs.sentry.io/platforms/elixir/testing.md#transactions)

Transactions in Phoenix are captured automatically by the Sentry plug. Enable tracing in `setup_sentry/1` and assert with `assert_sentry_report(:transaction, criteria)`:

```elixir
defmodule MyAppWeb.ProductControllerTest do
  use MyAppWeb.ConnCase, async: true

  import Sentry.Test.Assertions

  setup do
    Sentry.Test.setup_sentry(traces_sample_rate: 1.0)
  end

  test "traces product listing requests", %{conn: conn} do
    get(conn, ~p"/api/products")

    assert_sentry_report(:transaction, transaction: "GET /api/products")
  end
end
```

## [Logs](https://docs.sentry.io/platforms/elixir/testing.md#logs)

Sentry logs flow through an async telemetry pipeline. Both assertion helpers below handle the wait automatically — they flush the pipeline and poll the collector until a matching log appears or the timeout elapses (default: 1000 ms).

Use `assert_sentry_report(:log, criteria)` when your test emits exactly one Sentry log and you want a straight criteria match. Given an `Accounts` module that logs failed authentication attempts:

```elixir
def authenticate(email, password) do
  case Repo.get_by(User, email: email) do
    nil ->
      Logger.warning("Failed login attempt", user_email: email)
      {:error, :invalid_credentials}

    user ->
      verify_password(user, password)
  end
end
```

With that in place, a test can assert on the reported log:

```elixir
test "logs failed login attempts" do
  Accounts.authenticate("ghost@example.com", "wrong")

  assert_sentry_report(:log, level: :warning, body: "Failed login attempt")
end
```

### [`assert_sentry_log/3`](https://docs.sentry.io/platforms/elixir/testing.md#assert_sentry_log3)

`assert_sentry_log/3` is the preferred helper for log assertions. It takes `level` and `body` as positional arguments and uses **find semantics** — it finds the first matching log among all captured logs rather than requiring exactly one. This makes it resilient when your code or the framework emits multiple logs in a single test.

```elixir
test "includes the email in failed login log attributes" do
  Accounts.authenticate("ghost@example.com", "wrong")

  assert_sentry_log(:warning, "Failed login attempt",
    attributes: %{user_email: "ghost@example.com"}
  )
end

test "logs failed logins with a regex when the message includes dynamic content" do
  Accounts.authenticate("ghost@example.com", "wrong")

  assert_sentry_log(:warning, ~r/Failed login/)
end
```

Because `assert_sentry_log` uses find semantics, you can call it multiple times in the same test to assert on several logs independently — each call removes the matched item, leaving the rest available for subsequent assertions. Given an order pipeline that emits a log at each stage:

```elixir
def place(user, cart) do
  Logger.info("Payment initiated", order_id: cart.id)

  with {:ok, payment} <- Payments.charge(user, cart.total),
       {:ok, _} <- Inventory.reserve(cart.items) do
    Logger.info("Inventory reserved", order_id: cart.id)
    Logger.info("Confirmation email enqueued", order_id: cart.id)
    {:ok, finalize(user, cart, payment)}
  end
end
```

The test asserts on all three logs in a single pass — each `assert_sentry_log` call consumes the matched item and leaves the others available:

```elixir
test "logs each stage of the order pipeline" do
  Orders.place(user, cart)

  assert_sentry_log(:info, "Payment initiated")
  assert_sentry_log(:info, "Inventory reserved")
  assert_sentry_log(:info, "Confirmation email enqueued")
end
```

## [Metrics](https://docs.sentry.io/platforms/elixir/testing.md#metrics)

Metric events flow through an async telemetry pipeline. Both assertion helpers below handle the wait automatically — they flush the pipeline and poll the collector until a matching metric appears or the timeout elapses (default: 1000 ms).

Use `assert_sentry_report(:metric, criteria)` when your test emits exactly one metric and you want a straight criteria match. Given an `Orders` module that tracks completed orders by plan tier:

```elixir
def complete(%Order{} = order) do
  with {:ok, order} <- mark_complete(order),
       :ok <- Mailer.send_receipt(order) do
    Sentry.Metrics.count("orders.completed", 1,
      attributes: %{plan: order.user.plan}
    )
    {:ok, order}
  end
end
```

With that in place, a test can assert on the reported metric:

```elixir
test "increments the completed orders counter by plan" do
  order = insert(:order, user: build(:user, plan: "pro"))
  Orders.complete(order)

  assert_sentry_report(:metric,
    type: :counter,
    name: "orders.completed",
    attributes: %{plan: %{value: "pro"}}
  )
end
```

### [`assert_sentry_metric/2`](https://docs.sentry.io/platforms/elixir/testing.md#assert_sentry_metric2)

`assert_sentry_metric/2` is the preferred helper for metric assertions. It takes the metric type (`:counter`, `:distribution`, or `:gauge`) as a positional first argument and uses **find semantics** — it finds the first matching metric among all captured metrics rather than requiring exactly one. This makes it resilient when a single request records several measurements at once.

```elixir
test "increments the completed orders counter by plan" do
  order = insert(:order, user: build(:user, plan: "pro"))
  Orders.complete(order)

  assert_sentry_metric(:counter,
    name: "orders.completed",
    attributes: %{plan: %{value: "pro"}}
  )
end
```

Because `assert_sentry_metric` uses find semantics, you can call it multiple times in the same test to assert on several metrics independently — each call removes the matched item, leaving the rest available for subsequent assertions. Given a checkout flow that records multiple measurements:

```elixir
def process(%Cart{} = cart) do
  with {:ok, order} <- place_order(cart),
       {:ok, _} <- Payments.charge(order) do
    Sentry.Metrics.count("checkout.completed", 1)
    Sentry.Metrics.distribution("checkout.value", order.total_cents)
    {:ok, order}
  end
end
```

The test asserts on both metrics in a single pass — each `assert_sentry_metric` call consumes the matched item and leaves the other available:

```elixir
test "records a count and a value distribution on successful checkout" do
  cart = build(:cart, items: [build(:item, price_cents: 4999)])
  Checkout.process(cart)

  assert_sentry_metric(:counter, name: "checkout.completed")
  assert_sentry_metric(:distribution, name: "checkout.value")
end
```

## [Check-ins](https://docs.sentry.io/platforms/elixir/testing.md#check-ins)

Cron check-ins aren't stored in the per-test collector that the other helpers read from. The SDK sends them to the test's local HTTP server, the same way it sends them to Sentry.

Use `setup_bypass_envelope_collector/2` to intercept them, then assert with `assert_sentry_report/2`. Given an Oban worker that wraps its job in a check-in:

```elixir
defmodule MyApp.Workers.NightlyReportWorker do
  use Oban.Worker, cron: {"0 3 * * *", __MODULE__}

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    {:ok, check_in_id} =
      Sentry.capture_check_in(status: :in_progress, monitor_slug: "nightly-report")

    Reports.generate()

    Sentry.capture_check_in(
      status: :ok,
      monitor_slug: "nightly-report",
      check_in_id: check_in_id
    )

    :ok
  end
end
```

The test drives the worker with `perform_job/2` and asserts on both check-ins:

```elixir
defmodule MyApp.Workers.NightlyReportWorkerTest do
  use MyApp.DataCase, async: true

  import Sentry.Test.Assertions

  setup do
    %{bypass: bypass} = Sentry.Test.setup_sentry()
    ref = Sentry.Test.setup_bypass_envelope_collector(bypass, type: "check_in")
    %{bypass: bypass, ref: ref}
  end

  test "sends in-progress and ok check-ins around job execution", %{bypass: bypass, ref: ref} do
    perform_job(NightlyReportWorker, %{})

    [started, finished] = Sentry.Test.collect_sentry_check_ins(ref, 2)
    assert_sentry_report(started, status: "in_progress", monitor_slug: "nightly-report")
    assert_sentry_report(finished, status: "ok", monitor_slug: "nightly-report")
  end
end
```

### [Collecting Several Envelope Types](https://docs.sentry.io/platforms/elixir/testing.md#collecting-several-envelope-types)

Available since: `v14.0.0`

The `:type` option also accepts a list. Use it when one action sends several kinds of envelopes that you want to assert on together. Given a job that reports its own failures:

```elixir
def generate_nightly(date) do
  {:ok, check_in_id} =
    Sentry.capture_check_in(status: :in_progress, monitor_slug: "nightly-report")

  try do
    build_report!(date)
    Sentry.capture_check_in(status: :ok, monitor_slug: "nightly-report", check_in_id: check_in_id)
  rescue
    exception ->
      Sentry.capture_exception(exception, stacktrace: __STACKTRACE__)
      Sentry.capture_check_in(status: :error, monitor_slug: "nightly-report", check_in_id: check_in_id)
  end
end
```

The test collects the error and both check-ins, then splits them by type with `extract_events/1` and `extract_check_ins/1`:

```elixir
test "reports the exception and an error check-in", %{bypass: bypass} do
  ref = Sentry.Test.setup_bypass_envelope_collector(bypass, type: ["event", "check_in"])

  Reports.generate_nightly(:invalid_date)

  envelopes = Sentry.Test.collect_envelopes(ref, 3)

  [event] = Sentry.Test.extract_events(envelopes)
  assert_sentry_report(event, level: "error")

  [_started, finished] = Sentry.Test.extract_check_ins(envelopes)
  assert_sentry_report(finished, status: "error", monitor_slug: "nightly-report")
end
```

## [Failed Deliveries](https://docs.sentry.io/platforms/elixir/testing.md#failed-deliveries)

Available since: `v14.0.0`

By default, the collector answers every envelope with a successful response. Pass a `:response` function to `setup_bypass_envelope_collector/2` to simulate a failed response from Sentry instead.

The function receives the `Plug.Conn` and the raw envelope body, and must return the response connection. The collector still records the envelope before the function runs.

This test simulates Sentry rejecting the envelope and checks that a synchronous capture returns an error:

```elixir
test "returns an error when Sentry rejects the event", %{bypass: bypass} do
  ref =
    Sentry.Test.setup_bypass_envelope_collector(bypass,
      response: fn conn, _body -> Plug.Conn.resp(conn, 413, "") end
    )

  assert {:error, %Sentry.ClientError{reason: :envelope_too_large}} =
           Sentry.capture_message("Payment failed", result: :sync)

  [event] = Sentry.Test.collect_sentry_events(ref, 1)
  assert_sentry_report(event, message: %{formatted: "Payment failed"})
end
```

**Don't simulate a `429` response.** The SDK treats it as a rate limit that applies to the whole test run, not only the current test. It stops sending data for 60 seconds, or for the `Retry-After` duration, so later tests stop receiving envelopes. The SDK retries most other error statuses, such as `503`, and waits between attempts, which makes each of those tests take about 15 seconds.

## [Structured Assertions](https://docs.sentry.io/platforms/elixir/testing.md#structured-assertions)

All `assert_sentry_*` helpers accept a keyword list of *criteria*. Each value is matched as follows:

* **Regex** — matched with `=~`
* **Plain map** (not a struct) — recursive subset match: every key in the expected map must exist with a matching value in the actual
* **Any other value** — compared with `==`

Atom keys work on both Elixir structs and decoded JSON maps (like check-in bodies), so you don't need to switch between string and atom keys.

All helpers return the matched item, so you can chain further assertions on the struct:

```elixir
event = assert_sentry_report(:event,
  level: :error,
  tags: %{"webhook.provider" => "github"}
)

assert [exception] = event.exception
assert exception.type == "GitHub.APIError"
assert exception.mechanism.handled == false
```

### [Multiple Items](https://docs.sentry.io/platforms/elixir/testing.md#multiple-items)

When a single action triggers several Sentry events, use `find_sentry_report!/2` to select a specific one:

```elixir
test "reports a Sentry event for each failed subscription renewal" do
  BillingService.retry_failed_subscriptions()

  events = Sentry.Test.pop_sentry_reports()
  assert length(events) == 2

  ada_event = find_sentry_report!(events, user: %{email: "ada@example.com"})
  assert ada_event.tags["billing.reason"] == "card_declined"

  bob_event = find_sentry_report!(events, user: %{email: "bob@example.com"})
  assert bob_event.tags["billing.reason"] == "insufficient_funds"
end
```

### [Adjusting the Timeout](https://docs.sentry.io/platforms/elixir/testing.md#adjusting-the-timeout)

For slow background jobs or high-latency async pipelines, override the default 1000 ms timeout via the `:timeout` option:

```elixir
assert_sentry_log(:info, "PDF report generated", timeout: 5000)
assert_sentry_metric(:distribution, name: "report.duration", timeout: 5000)
```
