---
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 | Sentry 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`:

`mix.exs`

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

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
```

## [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:

`lib/my_app_web/controllers/webhook_controller.ex`

```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:

`test/my_app_web/controllers/webhook_controller_test.exs`

```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)`:

`test/my_app_web/controllers/product_controller_test.exs`

```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:

`lib/my_app/accounts.ex`

```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:

`test/my_app/accounts_test.exs`

```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.

`test/my_app/accounts_test.exs`

```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:

`lib/my_app/orders.ex`

```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:

`test/my_app/orders_test.exs`

```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 are also asynchronous. Use `assert_sentry_report(:metric, criteria)` — it awaits internally just like `assert_sentry_log`. Given an `Orders` module that tracks completed orders by plan tier:

`lib/my_app/orders.ex`

```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
```

The test asserts that the metric was emitted with the correct type, name, and attributes:

`test/my_app/orders_test.exs`

```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
```

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

Cron check-ins are delivered directly over the network, not through the ETS collector. 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:

`lib/my_app/workers/nightly_report_worker.ex`

```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:

`test/my_app/workers/nightly_report_worker_test.exs`

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

  import Sentry.Test.Assertions

  setup %{bypass: bypass} do
    Sentry.Test.setup_sentry()
    ref = Sentry.Test.setup_bypass_envelope_collector(bypass, type: "check_in")
    %{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
```

The `%{bypass: bypass}` map is returned by `setup_sentry/1` and merged into the test context automatically.

## [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:

`test/my_app/billing_test.exs`

```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_report(:metric, [name: "report.duration"], timeout: 5000)
```
