Test helpers for DurableObject applications.
Provides ergonomic helpers for testing Durable Objects. See the Testing Guide for detailed examples and patterns.
Usage
defmodule MyApp.CounterTest do
use ExUnit.Case
use DurableObject.Testing, repo: MyApp.Repo
test "increment works" do
{:ok, 1} = Counter.increment("test-counter", 1)
assert_persisted Counter, "test-counter", count: 1
end
endImportant: You must use ExUnit.Case before use DurableObject.Testing.
Options
:repo- The Ecto repo (required, or set via application config):prefix- Table prefix for multi-tenancy (optional)
Helpers
perform_handler/4- Unit test handler logic without GenServer/DBperform_alarm_handler/3- Unit test alarm handler logicassert_persisted/4- Assert object state in databaseget_persisted_state/3- Fetch persisted state for custom assertionsassert_alarm_scheduled/4- Assert alarm existsrefute_alarm_scheduled/4- Assert alarm does not existall_scheduled_alarms/3- List all alarms for an objectfire_alarm/4- Execute alarm immediately (bypasses scheduler)drain_alarms/3- Execute all pending alarmsassert_eventually/2- Poll until condition is true
Limitations
- Tests cannot be
async: true(sandbox runs in shared mode) fire_alarm/4starts the object if not runningdrain_alarms/3can hang on infinite alarm loops (use:max_iterations)
Summary
Functions
Sets up DurableObject test helpers.
Returns all scheduled alarms for the given object.
Asserts that an alarm is scheduled for the given object.
Polls a condition until it returns truthy or times out.
Asserts that an object's state was persisted to the database.
Fires all scheduled alarms for an object, regardless of scheduled time.
Fires a specific scheduled alarm immediately, bypassing scheduler timing.
Returns the persisted state for an object, or nil if not found.
Executes an alarm handler directly, bypassing GenServer and persistence.
Executes a handler function directly, bypassing GenServer and persistence.
Asserts that no alarm with the given name is scheduled.
Functions
Sets up DurableObject test helpers.
Injects a setup callback that:
- Checks out an Ecto sandbox connection
- Sets sandbox mode to
{:shared, self()}for cross-process access - Stores repo and prefix in process dictionary for helper functions
Important: You must use ExUnit.Case before use DurableObject.Testing
because this macro injects a setup callback.
Options
:repo- The Ecto repo (required, or set via application config):prefix- Table prefix for multi-tenancy (optional)
Example
defmodule MyApp.CounterTest do
use ExUnit.Case # Must come first!
use DurableObject.Testing, repo: MyApp.Repo
test "increment works" do
{:ok, 1} = Counter.increment("test-1", 1)
assert_persisted Counter, "test-1", count: 1
end
end
@spec all_scheduled_alarms(module(), String.t(), keyword()) :: [ %{name: atom(), scheduled_at: DateTime.t()} ]
Returns all scheduled alarms for the given object.
Useful for detailed assertions on alarm state when assert_alarm_scheduled
is not sufficient.
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
alarms = all_scheduled_alarms(Counter, "user-123")
assert length(alarms) == 2
assert Enum.any?(alarms, & &1.name == :cleanup)Returns
A list of maps with :name (atom) and :scheduled_at (DateTime), sorted
by scheduled_at ascending (earliest first).
Asserts that an alarm is scheduled for the given object.
Queries the durable_object_alarms table directly to check if an alarm
with the given name exists for the object.
Options
:within- Assert alarm is scheduled within this duration from now (milliseconds). If the alarm'sscheduled_atis further in the future, the assertion fails.:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
assert_alarm_scheduled Counter, "user-123", :cleanup
assert_alarm_scheduled Counter, "user-123", :cleanup, within: :timer.hours(1)Raises
Raises ExUnit.AssertionError if:
- No alarm with the given name is scheduled
:withinis specified and the alarm is scheduled beyond that window
Polls a condition until it returns truthy or times out.
Use sparingly - prefer deterministic assertions when possible. This is intended for testing truly asynchronous behavior where you can't control timing (e.g., waiting for a process to terminate).
Options
:timeout- Maximum wait in milliseconds (default: 5000):interval- Polling interval in milliseconds (default: 50)
Examples
# Wait for object to shut down
assert_eventually fn ->
DurableObject.whereis(Counter, id) == nil
end, timeout: 1000
# With custom interval for expensive checks
assert_eventually fn ->
get_persisted_state(Counter, id) != nil
end, timeout: 2000, interval: 100Returns
:ok- Condition became truthy
Raises
Raises ExUnit.AssertionError if the condition doesn't become truthy
within the timeout. The error message is generic ("Condition did not
become true within timeout") - consider wrapping in a more descriptive
assertion if needed.
Implementation Note
Uses System.monotonic_time/1 for timeout tracking, which is not affected
by system clock changes. The condition function is called once immediately,
then after each interval until timeout.
Asserts that an object's state was persisted to the database.
Can optionally assert on specific field values.
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
# Assert object exists in DB (any state)
assert_persisted Counter, "user-123"
# Assert specific fields (keyword list)
assert_persisted Counter, "user-123", count: 5
# Assert specific fields (map)
assert_persisted Counter, "user-123", %{count: 5, name: "test"}
# With explicit options
assert_persisted Counter, "user-123", [count: 5], repo: MyRepoRaises
Raises ExUnit.AssertionError if:
- No state is persisted for the object
- Any expected field value doesn't match the persisted value
Fires all scheduled alarms for an object, regardless of scheduled time.
Useful for testing alarm chains or ensuring all cleanup alarms run. Alarms are fired in scheduled order (earliest first). If firing an alarm schedules a new alarm, it will also be fired (recursively).
Warning: This can hang or raise if alarms reschedule indefinitely.
Use the :max_iterations option to protect against infinite loops.
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy:max_iterations- Maximum number of alarms to fire (default: 100). Raises if exceeded.
Examples
# Fire all alarms including any that get scheduled during execution
{:ok, 2} = drain_alarms(Counter, "user-123")
# With custom iteration limit for alarm chains
{:ok, _count} = drain_alarms(Counter, "user-123", max_iterations: 10)Returns
{:ok, count}- All alarms were drained, returns number of alarms fired
Raises
Raises if :max_iterations is exceeded (possible infinite alarm loop).
Fires a specific scheduled alarm immediately, bypassing scheduler timing.
Runs the alarm handler deterministically without waiting for scheduler polling. The alarm is deleted after successful execution (unless the handler reschedules the same alarm).
Important: This function starts the DurableObject if it's not running.
If your test depends on the object NOT being started, use perform_alarm_handler/3
for unit testing instead.
How It Works
- Verifies the alarm exists in the database
- Calls
DurableObject.call(module, object_id, :__fire_alarm__, [alarm_name]) - If successful, checks if the alarm was rescheduled (by comparing
scheduled_at) - Deletes the alarm only if it wasn't rescheduled
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
:ok = Counter.schedule_alarm("user-123", :cleanup, :timer.hours(1))
fire_alarm(Counter, "user-123", :cleanup)
refute_alarm_scheduled Counter, "user-123", :cleanupReturns
:ok- Alarm was fired successfully{:error, reason}- The alarm handler returned an error
Raises
Raises ArgumentError if no alarm with the given name is scheduled.
Returns the persisted state for an object, or nil if not found.
Useful for custom assertions beyond what assert_persisted/4 provides.
Top-level field keys are returned as atoms. Keys within field values
remain as strings (the raw DB form), regardless of the object_keys setting.
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
state = get_persisted_state(Counter, "user-123")
assert state.count > 0
assert state.name =~ ~r/test/
# Nested keys are always strings, even with object_keys: :atoms!
assert state.metadata == %{"foo" => "bar"}
# Returns nil if not persisted
assert nil == get_persisted_state(Counter, "nonexistent")
@spec perform_alarm_handler(module(), atom(), map()) :: {:noreply, map()} | {:noreply, map(), {:schedule_alarm, atom(), pos_integer()}} | {:error, term()}
Executes an alarm handler directly, bypassing GenServer and persistence.
This is useful for unit testing alarm handler logic in isolation. The
handle_alarm/2 callback is called directly with the alarm name and state.
Note: Unlike regular handlers, alarm handlers are dispatched through a single
handle_alarm(alarm_name, state) function that pattern matches on the alarm name.
Examples
assert {:noreply, %{count: 0}} =
perform_alarm_handler(Counter, :daily_reset, %{count: 42})
# Alarm that reschedules itself
assert {:noreply, %{count: 0}, {:schedule_alarm, :daily_reset, 86400000}} =
perform_alarm_handler(Counter, :daily_reset, %{count: 42})Parameters
module- The DurableObject modulealarm_name- The alarm name (atom)state- The state map
Returns
Returns whatever the handler returns (not wrapped):
{:noreply, new_state}- Alarm handler completed{:noreply, new_state, {:schedule_alarm, name, delay}}- Completed with reschedule{:error, reason}- Handler returned an error{:error, :no_alarm_handler}- Module has nohandle_alarm/2function
Note: If handle_alarm/2 exists but doesn't have a clause for the given
alarm name, this will raise a FunctionClauseError (not return an error tuple).
@spec perform_handler(module(), atom(), list(), map()) :: {:reply, term(), map()} | {:reply, term(), map(), {:schedule_alarm, atom(), pos_integer()}} | {:reply, term()} | {:noreply, map()} | {:noreply, map(), {:schedule_alarm, atom(), pos_integer()}} | {:error, term()}
Executes a handler function directly, bypassing GenServer and persistence.
This is useful for unit testing handler logic in isolation. The handler
function handle_<name>/N is called directly with the provided args and state.
Note: This does NOT validate that the handler is declared in the DSL's
handlers block - it only checks if the function exists. This means you
can test private helper handlers that aren't exposed via the DSL.
Examples
# Handler that takes no args - calls handle_increment(state)
assert {:reply, 1, %{count: 1}} =
perform_handler(Counter, :increment, [], %{count: 0})
# Handler that takes args - calls handle_increment_by(5, state)
assert {:reply, 5, %{count: 5}} =
perform_handler(Counter, :increment_by, [5], %{count: 0})
# Handler that returns error
assert {:error, :invalid_amount} =
perform_handler(Counter, :increment_by, [-1], %{count: 0})Parameters
module- The DurableObject modulehandler_name- The handler name (atom), will callhandle_<name>/Nargs- List of arguments to pass before statestate- The state map to pass as the last argument
Returns
Returns whatever the handler returns (not wrapped):
{:reply, result, new_state}- Handler returned a reply with state change{:reply, result, new_state, {:schedule_alarm, name, delay}}- Reply with alarm{:reply, result}- Read-only handler (no state change){:noreply, new_state}- Handler returned no reply{:noreply, new_state, {:schedule_alarm, name, delay}}- No reply with alarm{:error, reason}- Handler returned an error{:error, {:unknown_handler, name}}- Handler function doesn't exist
Asserts that no alarm with the given name is scheduled.
Options
:repo- Ecto repo (defaults to test case repo from process dictionary):prefix- Table prefix for multi-tenancy
Examples
refute_alarm_scheduled Counter, "user-123", :cleanupRaises
Raises ExUnit.AssertionError if an alarm with the given name exists.