DurableObject.Testing (DurableObject v0.2.1)

Copy Markdown View Source

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
end

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

Limitations

  • Tests cannot be async: true (sandbox runs in shared mode)
  • fire_alarm/4 starts the object if not running
  • drain_alarms/3 can 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

__using__(opts)

(macro)

Sets up DurableObject test helpers.

Injects a setup callback that:

  1. Checks out an Ecto sandbox connection
  2. Sets sandbox mode to {:shared, self()} for cross-process access
  3. 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

all_scheduled_alarms(module, object_id, opts \\ [])

@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).

assert_alarm_scheduled(module, object_id, alarm_name, opts \\ [])

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's scheduled_at is 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
  • :within is specified and the alarm is scheduled beyond that window

assert_eventually(condition_fn, opts \\ [])

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

Returns

  • :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.

assert_persisted(module, object_id, expected \\ nil, opts \\ [])

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

Raises

Raises ExUnit.AssertionError if:

  • No state is persisted for the object
  • Any expected field value doesn't match the persisted value

drain_alarms(module, object_id, opts \\ [])

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

fire_alarm(module, object_id, alarm_name, opts \\ [])

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

  1. Verifies the alarm exists in the database
  2. Calls DurableObject.call(module, object_id, :__fire_alarm__, [alarm_name])
  3. If successful, checks if the alarm was rescheduled (by comparing scheduled_at)
  4. 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", :cleanup

Returns

  • :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.

get_persisted_state(module, object_id, opts \\ [])

@spec get_persisted_state(module(), String.t(), keyword()) :: map() | nil

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")

perform_alarm_handler(module, alarm_name, state)

@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 module
  • alarm_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 no handle_alarm/2 function

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

perform_handler(module, handler_name, args, state)

@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 module
  • handler_name - The handler name (atom), will call handle_<name>/N
  • args - List of arguments to pass before state
  • state - 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

refute_alarm_scheduled(module, object_id, alarm_name, opts \\ [])

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", :cleanup

Raises

Raises ExUnit.AssertionError if an alarm with the given name exists.