Powered by AppSignal & Oban Pro

Test Actions

guides/actions/testing-actions.livemd

Test Actions

In this tutorial you write ExUnit tests for an Action at each level: its body, its schemas, its failures, its retries, and its telemetry. You need no external service. For testing across Agents and Flows, see Test Jido Code.

Mix.install([
  {:jido, github: "mikehostetler/new_jido", branch: "main"}
])

Start ExUnit without its automatic run, so each cell can run its own tests.

ExUnit.start(autorun: false)

Write an Action to test

This Action charges an invoice through a gateway module from context. Tests replace the gateway, so no network is involved.

defmodule TestingActions.Gateway do
  def charge(_customer_id, _amount_cents), do: {:error, :not_configured}
end

defmodule TestingActions.ChargeInvoice do
  alias Jido.Joi

  use Jido.Action,
    name: "charge_invoice",
    schema:
      Joi.object(%{
        customer_id: Joi.string() |> Joi.min(1),
        amount_cents: Joi.integer() |> Joi.min(1),
        currency: Joi.string() |> Joi.default("USD")
      }),
    output_schema:
      Joi.object(%{
        receipt_id: Joi.string(),
        amount_cents: Joi.integer()
      })

  @impl true
  def run(params, context) do
    gateway = Map.get(context, :gateway, TestingActions.Gateway)

    case gateway.charge(params.customer_id, params.amount_cents) do
      {:ok, receipt_id} ->
        {:ok, %{receipt_id: receipt_id, amount_cents: params.amount_cents},
         [{:send_receipt, params.customer_id, receipt_id}]}

      {:error, :card_declined} ->
        {:error, Jido.Error.execution_error("card declined", %{retry: false})}

      {:error, reason} ->
        {:error, reason}
    end
  end
end

defmodule TestingActions.ApprovingGateway do
  def charge(customer_id, _amount_cents), do: {:ok, "receipt-" <> customer_id}
end

defmodule TestingActions.DecliningGateway do
  def charge(_customer_id, _amount_cents), do: {:error, :card_declined}
end

Test the body and the boundary

Call run/2 to test the function body with exact inputs. Call Jido.Exec.run/4 to test what callers receive. Prefer Jido.Exec.run/4 for most tests, because it applies validation and error handling.

defmodule TestingActions.ChargeTest do
  use ExUnit.Case, async: true

  alias TestingActions.{ApprovingGateway, ChargeInvoice, DecliningGateway}

  @params %{customer_id: "c-1", amount_cents: 500}

  test "run/2 returns output and an effect request" do
    params = Map.put(@params, :currency, "USD")

    assert ChargeInvoice.run(params, %{gateway: ApprovingGateway}) ==
             {:ok, %{receipt_id: "receipt-c-1", amount_cents: 500},
              [{:send_receipt, "c-1", "receipt-c-1"}]}
  end

  test "Exec returns the validated result and the effects" do
    assert {:ok, %{receipt_id: "receipt-c-1"}, [{:send_receipt, "c-1", "receipt-c-1"}]} =
             Jido.Exec.run(ChargeInvoice, @params, %{gateway: ApprovingGateway})
  end

  test "a declined card has no effects and is not retryable" do
    assert {:error, %Jido.Error.ExecutionError{message: "card declined"} = error} =
             Jido.Exec.run(ChargeInvoice, @params, %{gateway: DecliningGateway})

    refute Jido.Error.retryable?(error)
  end
end

%{failures: 0, total: 3} = ExUnit.run()

The Action body runs in a task, not in the test process, under Jido.Exec.run/4. The task carries the test process in $callers, which libraries such as Mox use to find ownership. Process dictionary values do not cross over. Use context for test doubles, or call Jido.Exec.call_action/3 to stay in the test process.

Test the schemas

Call the generated validators to test schema rules without running work. Match code and path, not the message.

defmodule TestingActions.SchemaTest do
  use ExUnit.Case, async: true

  alias TestingActions.ChargeInvoice

  test "applies defaults" do
    assert {:ok, %{currency: "USD"}} =
             ChargeInvoice.validate_params(%{customer_id: "c-1", amount_cents: 500})
  end

  test "reports every invalid field by path" do
    assert {:error, %Jido.Error.ValidationError{details: %{errors: errors}}} =
             ChargeInvoice.validate_params(%{customer_id: "", amount_cents: 0})

    assert [[:amount_cents], [:customer_id]] == errors |> Enum.map(& &1.path) |> Enum.sort()
  end

  test "rejects output that breaks the contract" do
    assert {:error, %Jido.Error.ValidationError{}} =
             ChargeInvoice.validate_output(%{receipt_id: 42, amount_cents: 500})
  end

  test "publishes the contract" do
    contract = ChargeInvoice.authoring_contract()

    assert contract["name"] == "charge_invoice"
    assert "amount_cents" in contract["input_schema"]["required"]
  end
end

%{failures: 0, total: 4} = ExUnit.run()

Test retries and timeouts

Use a shared counter from :counters to count attempts across processes. Use a blocking receive to force a timeout without a real delay.

defmodule TestingActions.FlakyGateway do
  use Jido.Action, name: "flaky_gateway"

  @impl true
  def run(%{fail_times: fail_times}, %{attempts: attempts}) do
    :counters.add(attempts, 1, 1)

    if :counters.get(attempts, 1) <= fail_times,
      do: {:error, :gateway_unavailable},
      else: {:ok, %{attempts: :counters.get(attempts, 1)}}
  end
end

defmodule TestingActions.StuckGateway do
  use Jido.Action, name: "stuck_gateway"

  @impl true
  def run(_params, %{test_pid: test_pid}) do
    send(test_pid, {:attempt_started, self()})

    receive do
      :never -> {:ok, %{}}
    end
  end
end

defmodule TestingActions.PolicyTest do
  use ExUnit.Case, async: true

  alias TestingActions.{FlakyGateway, StuckGateway}

  setup do
    {:ok, attempts: :counters.new(1, [])}
  end

  test "retries until success", %{attempts: attempts} do
    assert {:ok, %{attempts: 3}} =
             Jido.Exec.run(FlakyGateway, %{fail_times: 2}, %{attempts: attempts}, max_attempts: 3)
  end

  test "stops at max_attempts", %{attempts: attempts} do
    assert {:error, %Jido.Error.ExecutionError{details: %{reason: :gateway_unavailable}}} =
             Jido.Exec.run(FlakyGateway, %{fail_times: 9}, %{attempts: attempts}, max_attempts: 2)

    assert :counters.get(attempts, 1) == 2
  end

  test "kills the attempt at the timeout" do
    assert {:error, %Jido.Error.TimeoutError{timeout: 20}} =
             Jido.Exec.run(StuckGateway, %{}, %{test_pid: self()}, timeout: 20)

    assert_received {:attempt_started, attempt_pid}
    monitor = Process.monitor(attempt_pid)
    assert_receive {:DOWN, ^monitor, :process, ^attempt_pid, _reason}
  end
end

%{failures: 0, total: 3} = ExUnit.run()

Keep :base_delay_ms at its default in tests. Retries then run with no delay.

Test telemetry

Attach a handler to Jido.Telemetry.Exec.event_names/0. Telemetry handlers run in the Action process, so send events to the test process. Give each test its own handler ID, and filter by Action, because other tests also emit events.

defmodule TestingActions.TelemetryTest do
  use ExUnit.Case, async: true

  alias TestingActions.{ApprovingGateway, ChargeInvoice}

  def forward(event, _measurements, metadata, test_pid) do
    if metadata[:action] == ChargeInvoice, do: send(test_pid, {:telemetry, event, metadata})
  end

  test "reports the outcome and effect count", %{test: test} do
    :ok = :telemetry.attach_many(test, Jido.Telemetry.Exec.event_names(), &__MODULE__.forward/4, self())
    on_exit(fn -> :telemetry.detach(test) end)

    assert {:ok, _output, [_effect]} =
             Jido.Exec.run(
               ChargeInvoice,
               %{customer_id: "c-1", amount_cents: 500},
               %{gateway: ApprovingGateway}
             )

    assert_received {:telemetry, [:jido, :action, :start], %{action_name: "charge_invoice", attempt: 0}}
    assert_received {:telemetry, [:jido, :action, :stop], %{outcome: :ok, effect_count: 1}}
  end
end

%{failures: 0, total: 1} = ExUnit.run()

Next steps