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, livebook: %{

runnable: true,
required_env_vars: [],
requires_network: false,
setup_instructions: "No API keys required. Run the setup cell, then execute the examples in order."

} }


Setup

Mix.install([
  {{mix_dep:jido}},
  {{mix_dep:jido_ai}}
])

Logger.configure(level: :warning)

import ExUnit.Assertions

Jido.start()
runtime = Jido.default_instance()

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

This guide runs entirely locally. No provider keys or network calls are required.

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.class == :execution
assert error.phase == :execution

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, pid} =
  Jido.start_agent(runtime, 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, pid} =
  Jido.start_agent(runtime, 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.start_agent(
  runtime,
  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.start_agent(
  runtime,
  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.