ObanClaude.Testing (oban_claude v0.3.0)

Copy Markdown View Source

Build the values a :query_fun returns, without knowing claude_wrapper's struct shapes.

ObanClaude.run/2 and ObanClaude.Worker call claude through a :query_fun (ObanClaude.query_fun/0): a 2-arity (prompt, opts) returning {:ok, %ClaudeWrapper.Result{}} or {:error, %ClaudeWrapper.Error{}}. In a test you pass a stub :query_fun so no real (paid) claude call happens.

Hand-writing that stub means knowing wrapper internals: success is a %Result{is_error: false}; structured output lives at extra["structured_output"] (where ObanClaude.structured/1 and ObanClaude.outcome/1 read it); a failure carries a %Error{kind: ...} from claude_wrapper's kind vocabulary. This module is those stubs, written once, so a shift in that representation surfaces here rather than silently in every consumer's tests.

Value builders

Return the bare wrapper structs -- for a hand-written :query_fun, or to feed handle_result/2 / ObanClaude.structured/1 directly in a unit test:

query_fun builders

Return a ready :query_fun (a 2-arity function):

  • respond/1 -- always succeeds with a result (or a plain string)
  • fail/1 -- always fails with an error (or a plain kind atom)
  • sequence/1 -- returns each scripted value in turn, for retry tests

Examples

Through the full seam with ObanClaude.run/2:

import ObanClaude.Testing

test "a clean result classifies to {:ok, result}" do
  assert {:ok, %Result{result: "done"}} =
           ObanClaude.run(%{"prompt" => "x"}, query_fun: respond("done"))
end

test "an auth failure cancels" do
  assert {{:cancel, :auth}, _} =
           ObanClaude.run(%{"prompt" => "x"}, query_fun: fail(:auth))
end

Structured output a handle_result/2 can read back:

test "reads the structured verdict" do
  result = structured_result(%{"outcome" => "blocked"})
  assert ObanClaude.outcome(result) == "blocked"
end

A worker's retry path, scripting one failure then a success:

qf = sequence([error(:auth, reason: :rate_limit), "done"])
assert {{:error, :rate_limit}, _} = ObanClaude.run(%{"prompt" => "x"}, query_fun: qf)
assert {:ok, %Result{result: "done"}} = ObanClaude.run(%{"prompt" => "x"}, query_fun: qf)

Only a :query_fun runtime value

respond/1, fail/1, and sequence/1 return function values, so pass them to ObanClaude.run/2 (or a worker's perform) at runtime. The use ObanClaude.Worker, query_fun: ... option is captured at compile time and needs a function capture (&Mod.fun/2) -- have that named function build its return with result/1/error/2 instead.

Summary

Types

What fail/1 (and sequence/1) accept: an %Error{} or a plain error-kind atom.

What respond/1 (and sequence/1) accept: a %Result{} or a plain result string.

Functions

An error %ClaudeWrapper.Error{} of kind.

A :query_fun that always fails, returning {:error, error}.

A :query_fun that always succeeds, returning {:ok, result}.

A success %ClaudeWrapper.Result{}.

A :query_fun that returns each of values in turn: call 1 gets the first, call 2 the second, and so on. For retry tests, where a worker's first attempt fails and a later one succeeds.

A success %Result{} carrying data as its structured output.

Types

failable()

What fail/1 (and sequence/1) accept: an %Error{} or a plain error-kind atom.

respondable()

@type respondable() :: ClaudeWrapper.Result.t() | String.t()

What respond/1 (and sequence/1) accept: a %Result{} or a plain result string.

Functions

error(kind, opts \\ [])

An error %ClaudeWrapper.Error{} of kind.

opts are forwarded to ClaudeWrapper.Error.new/2 (:reason, :message, :exit_code, :stdout, :stderr).

ObanClaude.Testing.error(:timeout)
ObanClaude.Testing.error(:auth, reason: :rate_limit)

fail(err)

@spec fail(failable()) :: ObanClaude.query_fun()

A :query_fun that always fails, returning {:error, error}.

err is an %Error{} (from error/2) or a plain kind atom (shorthand for error(kind)).

ObanClaude.run(args, query_fun: ObanClaude.Testing.fail(:auth))

respond(resp)

@spec respond(respondable()) :: ObanClaude.query_fun()

A :query_fun that always succeeds, returning {:ok, result}.

resp is a %Result{} (from result/1 or structured_result/2) or a plain string (shorthand for result(string)).

ObanClaude.run(args, query_fun: ObanClaude.Testing.respond("done"))

result(text_or_opts \\ "")

@spec result(String.t() | keyword()) :: ClaudeWrapper.Result.t()

A success %ClaudeWrapper.Result{}.

Pass a plain string for the result text, or a keyword list to set other fields (:result, :cost_usd, :num_turns, :session_id, :duration_ms).

ObanClaude.Testing.result("done")
ObanClaude.Testing.result(result: "done", cost_usd: 0.01, num_turns: 2)

sequence(values)

@spec sequence([respondable() | failable()]) :: ObanClaude.query_fun()

A :query_fun that returns each of values in turn: call 1 gets the first, call 2 the second, and so on. For retry tests, where a worker's first attempt fails and a later one succeeds.

Each value is coerced as in respond/1/fail/1: a %Result{} or string becomes {:ok, ...}; an %Error{} or kind atom becomes {:error, ...}. Raises once the script is exhausted, so a test that runs more attempts than it scripted fails loudly rather than silently reusing the last value.

# attempt 1 rate-limited, attempt 2 succeeds
qf = ObanClaude.Testing.sequence([ObanClaude.Testing.error(:auth, reason: :rate_limit), "done"])

Backed by an Agent linked to the calling process, so call it from the test process (not a compile-time use default).

structured_result(data, opts \\ [])

@spec structured_result(
  map() | list(),
  keyword()
) :: ClaudeWrapper.Result.t()

A success %Result{} carrying data as its structured output.

data is planted at extra["structured_output"], exactly where ObanClaude.structured/1 and ObanClaude.outcome/1 read it. opts sets the other Result fields, as in result/1.

ObanClaude.Testing.structured_result(%{"outcome" => "blocked"})
ObanClaude.Testing.structured_result(%{"pr" => 42}, cost_usd: 0.03)