Test Jido Code
This tutorial writes ExUnit tests for an invoice system at each level: an Action, a Flow, an Agent Turn, and a live Agent. Most tests need no process, so they stay fast and can run with async: true.
Install Jido
Mix.install([
{:jido, github: "mikehostetler/new_jido", branch: "main"}
])
Start ExUnit without its automatic run, so each cell can run its own tests. In a Mix project, put each test module in test/ and run mix test. test/test_helper.exs and Mix then handle the start and the run.
ExUnit.start(autorun: false)
The code under test
This is a smaller version of the Agent from Build Your First Agent. ChargeCard gets its gateway from context, so a test can replace it.
defmodule TestingJido.Gateway do
def charge(_amount_cents), do: {:error, :not_configured}
end
defmodule TestingJido.ChargeCard do
use Jido.Action,
name: "testing_jido_charge_card",
schema: Jido.Joi.object(%{amount_cents: Jido.Joi.integer() |> Jido.Joi.min(1)})
@impl true
def run(%{amount_cents: amount}, context) do
gateway = Map.get(context, :gateway, TestingJido.Gateway)
case gateway.charge(amount) do
{:ok, receipt_id} -> {:ok, %{amount_cents: amount, receipt_id: receipt_id}}
{:error, reason} -> {:error, Jido.Error.execution_error("charge failed", %{reason: reason, retry: false})}
end
end
end
defmodule TestingJido.RecordPayment do
use Jido.Action, name: "testing_jido_record_payment"
@impl true
def run(%{amount_cents: amount}, %{agent_state: state}) do
{:ok, %{state | paid_cents: state.paid_cents + amount}}
end
end
defmodule TestingJido.PayInvoice do
use Jido.Flow,
name: "testing_jido_pay_invoice",
schema: Jido.Joi.object(%{amount_cents: Jido.Joi.integer()}),
output_schema: Jido.Joi.object(%{paid_cents: Jido.Joi.integer()})
flow do
step "charge", action: TestingJido.ChargeCard, params: %{amount_cents: input(:amount_cents)}
step "record",
action: TestingJido.RecordPayment,
params: %{amount_cents: result("charge", :amount_cents)}
output result("record")
end
end
defmodule TestingJido.InvoiceAgent do
use Jido.Agent, name: "testing_jido_invoice"
state do
schema Jido.Joi.object(%{paid_cents: Jido.Joi.integer() |> Jido.Joi.default(0)})
end
routes do
signal_source "/billing/invoice"
route "invoice.pay", TestingJido.PayInvoice, as: :pay do
reply "invoice.paid", from: output(:paid_cents)
end
end
end
defmodule TestingJido.ApprovingGateway do
def charge(amount_cents), do: {:ok, "receipt-#{amount_cents}"}
end
defmodule TestingJido.DecliningGateway do
def charge(_amount_cents), do: {:error, :card_declined}
end
Test an Action
Run the Action through Jido.Exec.run/4 to test what callers receive. Pass a stub through context. Match on the error struct and its details, not on the message.
defmodule TestingJido.ChargeCardTest do
use ExUnit.Case, async: true
alias TestingJido.{ApprovingGateway, ChargeCard, DecliningGateway}
test "returns a receipt when the gateway approves" do
assert Jido.Exec.run(ChargeCard, %{amount_cents: 500}, %{gateway: ApprovingGateway}) ==
{:ok, %{amount_cents: 500, receipt_id: "receipt-500"}}
end
test "reports a declined card" do
assert {:error, %Jido.Error.ExecutionError{details: %{reason: :card_declined}}} =
Jido.Exec.run(ChargeCard, %{amount_cents: 500}, %{gateway: DecliningGateway})
end
test "rejects a zero amount by field" do
assert {:error, %Jido.Error.ValidationError{details: %{errors: [%{path: [:amount_cents]}]}}} =
Jido.Exec.run(ChargeCard, %{amount_cents: 0}, %{gateway: ApprovingGateway})
end
end
ExUnit.run() |> Map.take([:total, :failures])
The cell returns %{total: 3, failures: 0}. ExUnit.run/0 runs the test modules defined since its last run.
Test a Flow
A Flow test proves the components connect. It runs the real graph and checks which component failed. Context passes through to each Action, so the same stub works. RecordPayment needs :agent_state, which an Agent normally supplies, so the test passes it directly.
defmodule TestingJido.PayInvoiceTest do
use ExUnit.Case, async: true
alias TestingJido.{ApprovingGateway, DecliningGateway, PayInvoice}
test "records the charged amount" do
context = %{gateway: ApprovingGateway, agent_state: %{paid_cents: 100}}
assert Jido.Exec.run(PayInvoice, %{amount_cents: 500}, context) == {:ok, %{paid_cents: 600}}
end
test "stops at the charge step when the card is declined" do
context = %{gateway: DecliningGateway, agent_state: %{paid_cents: 100}}
assert {:error, %Jido.Error.ExecutionError{details: details}} =
Jido.Exec.run(PayInvoice, %{amount_cents: 500}, context)
assert details.node == "charge"
assert details.reason == :card_declined
end
end
ExUnit.run() |> Map.take([:total, :failures])
The cell returns %{total: 2, failures: 0}.
Test an Agent Turn
cmd/3 needs no process and performs no Directives, so a test can assert on the state and on the Directives themselves. The :context option passes the stub to the handler. Compare with the source value to prove that a failed Turn changed nothing.
defmodule TestingJido.InvoiceTurnTest do
use ExUnit.Case, async: true
alias TestingJido.{ApprovingGateway, DecliningGateway, InvoiceAgent}
setup do
%{invoice: InvoiceAgent.instantiate!(id: "inv-test")}
end
test "a payment updates state and requests a reply", %{invoice: invoice} do
signal = InvoiceAgent.pay_signal!(%{amount_cents: 500})
assert {:ok, paid, [%Jido.Agent.Directive.Reply{signal: reply}]} =
InvoiceAgent.cmd(invoice, signal, context: %{gateway: ApprovingGateway})
assert paid.state.paid_cents == 500
assert reply.type == "invoice.paid"
assert reply.data == 500
end
test "a declined card fails the Turn", %{invoice: invoice} do
signal = InvoiceAgent.pay_signal!(%{amount_cents: 500})
assert {:error, %Jido.Error.ExecutionError{}} =
InvoiceAgent.cmd(invoice, signal, context: %{gateway: DecliningGateway})
assert invoice.state.paid_cents == 0
end
end
ExUnit.run() |> Map.take([:total, :failures])
The cell returns %{total: 2, failures: 0}.
Test a live Agent
Add a small number of live tests for what only a server does: commit, state version, and delivery. Start the instance with start_supervised!/1, so it stops with the test. A unique name for each test keeps async: true safe. Jido.AgentServer.call/3 returns at the commit and the server performs Directives afterward, so wait for the reply with assert_receive.
defmodule TestingJido.InvoiceServerTest do
use ExUnit.Case, async: true
alias Jido.AgentServer
alias TestingJido.{ApprovingGateway, DecliningGateway, InvoiceAgent}
setup do
jido = :"testing_jido_#{System.unique_integer([:positive])}"
start_supervised!({Jido, name: jido})
{:ok, server} = Jido.start_agent(jido, InvoiceAgent, id: "inv-live")
:ok = AgentServer.await_ready(server)
%{server: server, signal: InvoiceAgent.pay_signal!(%{amount_cents: 500})}
end
test "commits the payment and delivers the reply", %{server: server, signal: signal} do
assert {:ok, committed, [_reply]} =
AgentServer.call(server, signal, context: %{gateway: ApprovingGateway})
assert committed.state.paid_cents == 500
assert AgentServer.status(server).state_version == 1
assert_receive %Jido.Signal{type: "invoice.paid", data: 500}
end
test "keeps the committed state after a failed Turn", %{server: server, signal: signal} do
assert {:error, _reason} = AgentServer.call(server, signal, context: %{gateway: DecliningGateway})
assert AgentServer.agent(server).state.paid_cents == 0
assert AgentServer.status(server).state_version == 0
assert Process.alive?(server)
end
end
ExUnit.run() |> Map.take([:total, :failures])
The cell returns %{total: 2, failures: 0}.
Choose the level
| What you want to prove | Test with |
|---|---|
| A body, a schema, or a failure in one unit of work | Jido.Exec.run/4 on the Action |
| Components connect, and a failure stops in the right place | Jido.Exec.run/4 on the Flow |
| A route, a state change, or a Directive | cmd/3 on an Agent instance |
| Commit, state version, or delivery | Jido.AgentServer.call/3 under a test instance |
Next steps
- Test Actions adds retries, timeouts, and telemetry.
- Test Flows adds stubbed Actions and managed runs.
- Test Agent Definitions tests route selection and rejected definitions.
- Test Jido Systems covers multi-Agent tests.