<!--
SPDX-FileCopyrightText: 2025 spark contributors <https://github.com/ash-project/spark/graphs.contributors>
SPDX-License-Identifier: MIT
-->
# Testing Spark Verifiers
Verifier errors raised inside Spark's `@after_verify` hook are caught by the
framework and emitted as stderr warnings instead of propagated as exceptions.
This keeps compilation flowing when a DSL is invalid, but it means the
natural ExUnit pattern — `assert_raise/2` — does not work.
`Spark.Test` provides ExUnit helpers that turn the captured errors and
warnings back into structured data your tests can pattern-match on.
## Quick reference
| Intent | Errors | Warnings |
|---|---|---|
| Collect everything as a list | `dsl_errors/1` | `dsl_warnings/1` |
| Assert at least one matches a pattern | `assert_dsl_error/2` (or `/1` for any) | `assert_dsl_warning/2` (or `/1` for any) |
| Assert nothing was produced | `refute_dsl_errors/1` | `refute_dsl_warnings/1` |
`import Spark.Test` makes all six available.
## Defining modules inside the helpers
This rule catches everyone. Inside the macro's anonymous-function body, a
bare alias like `BadPerson` resolves against the surrounding test module's
scope. Outside — where your `assert` patterns live — the same alias
resolves at the top level. They become different atoms, and your patterns
will never match.
Force top-level resolution with the explicit `Elixir.X` form:
```elixir
errors =
dsl_errors do
defmodule Elixir.BadPerson do # <-- note the Elixir. prefix
...
end
end
assert [{BadPerson, _}] = errors # <-- bare alias resolves to Elixir.BadPerson, matches
```
Always use the `Elixir.X` form for any module you intend to assert on.
## Testing a verifier that produces errors
Anything that returns `{:error, %Spark.Error.DslError{}}` from
`Spark.Dsl.Verifier.verify/1` — your own verifiers and Spark's built-ins
(`VerifyEntityUniqueness`, `VerifySectionSingletonEntities`).
### Match a specific error
`assert_dsl_error/2` is the most common shape. It takes a pattern, runs the
do-block, and returns the first matching error so you can do follow-up
assertions on it:
```elixir
defmodule MyApp.PersonValidatorTest do
use ExUnit.Case, async: true
import Spark.Test
test "rejects an invalid email" do
err =
assert_dsl_error %Spark.Error.DslError{path: [:fields, :email]} do
defmodule Elixir.BadPerson do
use MyApp.PersonValidator
fields do
field :email, :string do
check &String.contains?(&1, "@")
end
end
end
end
assert err.message =~ "invalid email"
end
end
```
The pattern is matched via `match?/2`, so any valid Elixir pattern works.
On no match the test fails with a message that includes the pattern source
and the inspected list of actual errors.
When the pattern doesn't matter, use the `/1` form:
```elixir
err =
assert_dsl_error do
defmodule Elixir.BadPerson do
# ...
end
end
```
### Assert clean compilation
For happy-path tests where no error should be produced:
```elixir
test "valid configuration is accepted" do
refute_dsl_errors do
defmodule Elixir.GoodPerson do
use MyApp.PersonValidator
fields do
field :email, :string
end
end
end
end
```
Returns `:ok` on success. On failure the message lists the offending
modules and their errors.
### Work with the full collected list
Use `dsl_errors/1` directly when you need to compare error counts, assert
on multiple distinct errors at once, or otherwise inspect the full result.
The shape is `[{module, [%Spark.Error.DslError{}]}, ...]` in definition
order:
```elixir
errors =
dsl_errors do
defmodule Elixir.HasTwoIssues do
# ...
end
end
assert [{HasTwoIssues, errors_list}] = errors
assert length(errors_list) == 2
```
## Testing a verifier that produces warnings
The same three helpers exist (`dsl_warnings/1`, `assert_dsl_warning/2,/1`,
`refute_dsl_warnings/1`) with one shape difference:
**Warnings are normalized to `{message, location | nil}` tuples**, not
`Spark.Error.DslError` structs. A bare-string warning `{:warn, "msg"}`
becomes `{"msg", nil}`; a tuple-form warning `{:warn, {"msg", anno}}`
preserves the location. Source annotations require `debug_info` to be
enabled for tests — see [Asserting on source
annotations](#asserting-on-source-annotations) below.
```elixir
test "deprecated option emits a warning" do
{message, _location} =
assert_dsl_warning {_, _} do
defmodule Elixir.UsesDeprecatedOption do
use MyApp.Validator
fields do
field :legacy_option, :string
end
end
end
assert message =~ "deprecated"
end
```
For literal-message matching put the string in the pattern:
```elixir
{_, _} =
assert_dsl_warning {"legacy_option is deprecated", _} do
# ...
end
```
`refute_dsl_warnings/1` and `dsl_warnings/1` work identically to their
errors counterparts, just with the warning payload shape.
## What the helpers don't capture
- **`Spark.Warning.warn/3` called directly** — for example by
`Spark.Options` for schema-level deprecation warnings, or by
`Spark.Dsl.Extension.__after_verify__/1` for `__spark_metadata__`
deprecation. These bypass the verifier-callback path. Use
`capture_io(:stderr, ...)` to assert on them.
- **Transformer warnings** — `{:warn, dsl_state, warnings}` returns from
`Spark.Dsl.Transformer.transform/1` flow through a separate code path
and are not collected.
## Asserting on source annotations
Spark only captures `:erl_anno.anno()` when `debug_info` is enabled. To
enable it for runtime modules compiled inside the helpers, add the
following to your `mix.exs`:
```elixir
test_elixirc_options: [debug_info: true]
```
Without it, `location` in `{message, location}` payloads is `nil` even when
the verifier passes the anno correctly.
## Async safety
The helpers are safe in `async: true` ExUnit tests. The collection
mechanism uses per-process state (`Process.put/2`) and point-to-point
`send/2`; nothing crosses process boundaries.
One caveat: any `defmodule` defined inside a process you spawn yourself
won't be captured, because the spawned process has its own dictionary.
Either keep `defmodule` calls in the same process as the helper, or set
the collector flag explicitly inside the spawned process:
```elixir
parent = self()
Task.async(fn ->
Process.put({Spark.Dsl, :test_collector}, parent)
defmodule Elixir.SomeModule do
# ...
end
end)
|> Task.await()
```
## See also
- `Spark.Test` — the helper module
- `Spark.Dsl.Verifier` — the verifier callback contract
- `Spark.Error.DslError` — the error struct returned by the error helpers