Powered by AppSignal & Oban Pro

Setup

testing-agents-and-actions.livemd

%{ title: “Testing Agents”, description: “Unit and integration test patterns for agents, actions, and runtime workflows.”, category: :docs, tags: [:docs, :guides, :livebook], order: 170, draft: false }


Setup

Mix.install([
  {:jido, "~> 2.0"},
  {:jido_ai, "~> 0.2"}
])

Jido.start()

Agents are immutable structs. Most tests need no processes, no mocks, and no async coordination. Call cmd/2, pattern match the result, and assert.

Testing actions in isolation

Actions are pure functions. Test them by calling run/2 directly with a params map and a context map.

Define an action

defmodule MyApp.IncrementAction do
  use Jido.Action,
    name: "increment",
    description: "Increments a counter",
    schema: [
      by: [type: :integer, default: 1, doc: "Amount to increment by"]
    ]

  @impl true
  def run(%{by: amount}, context) do
    current = Map.get(context.state, :count, 0)
    {:ok, %{count: current + amount}}
  end
end

Assert success cases

Pass the validated params and a context map containing the state your action reads from.

assert {:ok, %{count: 5}} =
  MyApp.IncrementAction.run(%{by: 5}, %{state: %{count: 0}})

assert {:ok, %{count: 13}} =
  MyApp.IncrementAction.run(%{by: 3}, %{state: %{count: 10}})

Assert error cases

Define an action that rejects invalid input and test the error path.

defmodule MyApp.DivideAction do
  use Jido.Action,
    name: "divide",
    description: "Divides value by divisor",
    schema: [
      divisor: [type: :integer, required: true, doc: "Divisor"]
    ]

  @impl true
  def run(%{divisor: 0}, _context), do: {:error, :division_by_zero}

  def run(%{divisor: d}, context) do
    value = Map.get(context.state, :value, 100)
    {:ok, %{value: div(value, d)}}
  end
end
assert {:error, :division_by_zero} =
  MyApp.DivideAction.run(%{divisor: 0}, %{state: %{}})

assert {:ok, %{value: 50}} =
  MyApp.DivideAction.run(%{divisor: 2}, %{state: %{value: 100}})

Testing agent state transitions

Define an agent and exercise it with cmd/2. Every call returns {agent, directives} where agent is a new immutable struct with updated state.

Define the agent

defmodule MyApp.CounterAgent do
  use Jido.Agent,
    name: "counter_agent",
    description: "Counts things",
    schema: [
      count: [type: :integer, default: 0]
    ]
end

Create and inspect initial state

agent = MyApp.CounterAgent.new()
assert agent.state.count == 0

agent = MyApp.CounterAgent.new(state: %{count: 10})
assert agent.state.count == 10

Run actions and assert state changes

agent = MyApp.CounterAgent.new()

{agent, _directives} =
  MyApp.CounterAgent.cmd(agent, {MyApp.IncrementAction, %{by: 3}})

assert agent.state.count == 3

State accumulates across sequential calls. Each cmd/2 returns a fresh struct.

agent = MyApp.CounterAgent.new()
{agent, _} = MyApp.CounterAgent.cmd(agent, {MyApp.IncrementAction, %{by: 2}})
{agent, _} = MyApp.CounterAgent.cmd(agent, {MyApp.IncrementAction, %{by: 5}})

assert agent.state.count == 7

Pass custom IDs

Override the agent ID for deterministic test assertions.

agent = MyApp.CounterAgent.new(id: "test-counter-1")
assert agent.id == "test-counter-1"

Asserting on directives

cmd/2 returns a list of directive structs alongside the updated agent. Directives describe external effects the runtime should execute - they are bare structs, not wrapped in tuples.

Match directive types

alias Jido.Agent.Directive

defmodule MyApp.EmitAction do
  use Jido.Action,
    name: "emit_result",
    description: "Emits a signal with the current count",
    schema: []

  @impl true
  def run(_params, context) do
    signal = Jido.Signal.new!("counter.updated", %{count: context.state.count}, source: "/counter")
    {:ok, %{}, [Directive.emit(signal)]}
  end
end
agent = MyApp.CounterAgent.new(state: %{count: 42})
{_agent, directives} = MyApp.CounterAgent.cmd(agent, MyApp.EmitAction)

assert [%Directive.Emit{signal: signal}] = directives
assert signal.type == "counter.updated"
assert signal.data.count == 42

Match error directives

When an action fails validation or returns an error, cmd/2 emits an Error directive instead of raising.

defmodule MyApp.BadAction do
  use Jido.Action,
    name: "bad_action",
    description: "Always fails",
    schema: []

  @impl true
  def run(_params, _context), do: {:error, :something_went_wrong}
end
agent = MyApp.CounterAgent.new()
{_agent, directives} = MyApp.CounterAgent.cmd(agent, MyApp.BadAction)

assert [%Directive.Error{error: error}] = directives
assert error.type == :action_error

Empty directives

Most actions produce no directives. Assert on the empty list to confirm no side effects.

agent = MyApp.CounterAgent.new()
{agent, directives} = MyApp.CounterAgent.cmd(agent, {MyApp.IncrementAction, %{by: 1}})

assert directives == []
assert agent.state.count == 1

Testing with the runtime

When you need to test signal routing, process lifecycle, or async behavior, start the agent in an AgentServer.

Start an agent server

{:ok, _} = Jido.start()

{:ok, pid} =
  Jido.start_agent(Jido.default_instance(), MyApp.CounterAgent)

Query state

state/1 returns the full server state struct. The agent struct lives at state.agent.

{:ok, server_state} = Jido.AgentServer.state(pid)
assert server_state.agent.state.count == 0

Send signals synchronously

call/2 sends a signal and waits for processing. It returns the updated agent struct.

defmodule MyApp.SignalCounterAgent do
  use Jido.Agent,
    name: "signal_counter",
    description: "Routes increment signals",
    schema: [
      count: [type: :integer, default: 0]
    ]

  @impl true
  def signal_routes(_ctx) do
    [{"counter.increment", MyApp.IncrementAction}]
  end
end
{:ok, _} = Jido.start()

{:ok, pid} =
  Jido.start_agent(Jido.default_instance(), MyApp.SignalCounterAgent)

signal = Jido.Signal.new!("counter.increment", %{by: 10}, source: "/test")
{:ok, agent} = Jido.AgentServer.call(pid, signal)

assert agent.state.count == 10

Send signals asynchronously

cast/2 returns :ok immediately. Query state after a short wait to verify processing.

signal = Jido.Signal.new!("counter.increment", %{by: 5}, source: "/test")
:ok = Jido.AgentServer.cast(pid, signal)

Process.sleep(100)

{:ok, server_state} = Jido.AgentServer.state(pid)
assert server_state.agent.state.count == 15

Using debug mode in tests

Debug mode records internal events in a ring buffer. Use it to verify that signals were received and directives were processed without inspecting internal state.

Enable at startup

{:ok, pid} = Jido.AgentServer.start_link(
  agent: MyApp.SignalCounterAgent,
  debug: true
)

Enable at runtime

:ok = Jido.AgentServer.set_debug(pid, true)

Retrieve recent events

Each event has :at (monotonic timestamp in ms), :type (atom), and :data (map).

signal = Jido.Signal.new!("counter.increment", %{by: 1}, source: "/test")
{:ok, _agent} = Jido.AgentServer.call(pid, signal)

{:ok, events} = Jido.AgentServer.recent_events(pid, limit: 10)
types = Enum.map(events, & &1.type)

assert :signal_received in types

Verify debug is required

recent_events/2 returns an error when debug mode is off. Use this to confirm your test setup.

{:ok, pid} = Jido.AgentServer.start_link(
  agent: MyApp.CounterAgent
)

assert {:error, :debug_not_enabled} =
  Jido.AgentServer.recent_events(pid, limit: 5)

ExUnit patterns

These patterns translate directly into ExUnit test files in a Mix project.

Test module skeleton

defmodule MyApp.CounterAgentTest do
  use ExUnit.Case, async: true

  alias MyApp.{CounterAgent, IncrementAction}

  describe "state transitions" do
    test "increments count" do
      agent = CounterAgent.new()
      {agent, _} = CounterAgent.cmd(agent, {IncrementAction, %{by: 3}})
      assert agent.state.count == 3
    end
  end
end

Testing signal routes

Verify that your agent maps signal types to the correct actions.

defmodule MyApp.SignalCounterAgentTest do
  use ExUnit.Case, async: true

  test "routes counter.increment to IncrementAction" do
    agent = MyApp.SignalCounterAgent.new()
    routes = MyApp.SignalCounterAgent.signal_routes(%{agent: agent})

    assert {"counter.increment", MyApp.IncrementAction} in routes
  end
end

Runtime tests with setup

For tests that need a running agent server, start the instance in a setup block.

defmodule MyApp.CounterServerTest do
  use ExUnit.Case, async: false

  setup do
    {:ok, _} = Jido.start()
    {:ok, pid} = Jido.start_agent(
      Jido.default_instance(),
      MyApp.SignalCounterAgent
    )
    %{pid: pid}
  end

  test "processes signals", %{pid: pid} do
    signal = Jido.Signal.new!("counter.increment", %{by: 7}, source: "/test")
    {:ok, agent} = Jido.AgentServer.call(pid, signal)
    assert agent.state.count == 7
  end
end

Next steps

Now that you have test patterns for agents and actions, explore related topics.