Current section

Files

Jump to
puck lib puck test.ex
Raw

lib/puck/test.ex

defmodule Puck.Test do
@moduledoc """
Test utilities for deterministic agent testing.
Creates mock clients with queued responses that work across process
boundaries, with optional verification that all responses were consumed.
## Usage
defmodule MyAgentTest do
use ExUnit.Case, async: true
setup :verify_on_exit!
test "agent completes workflow" do
client = Puck.Test.mock_client([
%{action: "search", query: "test"},
%{action: "done", result: "found"}
])
assert {:ok, result} = MyAgent.run(client: client)
assert result.action == "done"
end
end
"""
@ownership Puck.Test.Ownership
@doc false
def start_link(opts \\ []) do
NimbleOwnership.start_link(Keyword.put_new(opts, :name, @ownership))
end
defp ensure_started do
case start_link() do
{:ok, pid} ->
Process.unlink(pid)
:ok
{:error, {:already_started, _pid}} ->
:ok
end
end
@doc """
Creates a mock client with queued responses.
Each call to `Puck.call/3` or `Puck.stream/3` pops the next response from
the queue. Works across process boundaries.
## Response Types
- Any term (struct, map, string) — returned as response content
- `{:error, reason}` — simulates backend error
- `fn messages -> response end` — dynamic response based on conversation
## Options
- `:default` - Response when queue exhausts (default: `{:error, :mock_responses_exhausted}`)
- `:model` - Model name for introspection (default: `"mock"`)
## Examples
client = Puck.Test.mock_client([
%{action: "search"},
%{action: "done"}
])
client = Puck.Test.mock_client([
fn messages -> %{echo: length(messages)} end
])
client = Puck.Test.mock_client([
{:error, :rate_limited},
%{action: "retry_succeeded"}
])
"""
def mock_client(responses, opts \\ []) when is_list(responses) do
default = Keyword.get(opts, :default, {:error, :mock_responses_exhausted})
model = Keyword.get(opts, :model, "mock")
{:ok, queue_pid} = Agent.start_link(fn -> responses end)
track_mock(queue_pid, length(responses))
Puck.Client.new({Puck.Backends.Mock, queue_pid: queue_pid, default: default, model: model})
end
@doc """
Verifies all mock clients created by this process consumed all responses.
Raises `ExUnit.AssertionError` if any responses remain unconsumed.
Stops all tracked Agent processes.
## Example
client = Puck.Test.mock_client([%{action: "done"}])
Puck.call(client, "test", Puck.Context.new())
Puck.Test.verify!()
"""
def verify! do
do_verify(self())
end
@doc """
ExUnit setup callback for automatic verification on test exit.
setup :verify_on_exit!
"""
def verify_on_exit!(_context \\ %{}) do
pid = self()
ExUnit.Callbacks.on_exit(fn -> do_verify(pid) end)
:ok
end
defp track_mock(queue_pid, count) do
ensure_started()
pid = self()
key = {:puck_test_mocks, pid}
update_fn = fn
nil -> {:ok, [{queue_pid, count}]}
mocks -> {:ok, [{queue_pid, count} | mocks]}
end
case NimbleOwnership.get_and_update(@ownership, pid, key, update_fn) do
{:ok, _} ->
:ok
{:error, %NimbleOwnership.Error{}} ->
:ok = NimbleOwnership.allow(@ownership, pid, pid, key)
{:ok, _} = NimbleOwnership.get_and_update(@ownership, pid, key, update_fn)
:ok
end
end
defp do_verify(pid) do
ensure_started()
key = {:puck_test_mocks, pid}
try do
case NimbleOwnership.fetch_owner(@ownership, [pid], key) do
{:ok, ^pid} ->
{:ok, mocks} =
NimbleOwnership.get_and_update(@ownership, pid, key, fn m -> {m, nil} end)
verify_and_stop_mocks(mocks || [])
_ ->
:ok
end
catch
:exit, _ -> :ok
end
end
defp verify_and_stop_mocks(mocks) do
for {queue_pid, expected} <- mocks do
remaining = Agent.get(queue_pid, &length/1)
Agent.stop(queue_pid)
if remaining > 0 do
raise ExUnit.AssertionError,
message: "Puck.Test mock had #{remaining} of #{expected} responses unused"
end
end
:ok
end
end