Search
Search docs, blog posts, and ecosystem packages with citations.
Enter a query to see grounded citations.
We can't find the internet
Attempting to reconnect
Search docs, blog posts, and ecosystem packages with citations.
Unit and integration test patterns for agents, actions, and runtime workflows.
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.
Actions are pure functions. Test them by calling run/2 directly with a params map and a context map.
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
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}})
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}})
Define an agent and exercise it with cmd/2. Every call returns {agent, directives} where agent is a new immutable struct with updated state.
defmodule MyApp.CounterAgent do
use Jido.Agent,
name: "counter_agent",
description: "Counts things",
schema: [
count: [type: :integer, default: 0]
]
end
agent = MyApp.CounterAgent.new()
assert agent.state.count == 0
agent = MyApp.CounterAgent.new(state: %{count: 10})
assert agent.state.count == 10
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
Override the agent ID for deterministic test assertions.
agent = MyApp.CounterAgent.new(id: "test-counter-1")
assert agent.id == "test-counter-1"
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.
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
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
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
When you need to test signal routing, process lifecycle, or async behavior, start the agent in an AgentServer.
{:ok, _} = Jido.start()
{:ok, pid} =
Jido.start_agent(Jido.default_instance(), MyApp.CounterAgent)
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
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
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
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.
{:ok, pid} = Jido.AgentServer.start_link(
agent: MyApp.SignalCounterAgent,
debug: true
)
:ok = Jido.AgentServer.set_debug(pid, true)
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
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)
These patterns translate directly into ExUnit test files in a Mix project.
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
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
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
Now that you have test patterns for agents and actions, explore related topics.