First-class test doubles for applications built on ExDaytona — no Daytona account, API key, or network required.
client/1 returns a real ExDaytona.Client whose HTTP adapter and
streaming transports are backed by scripts owned by the calling (test)
process. Every facade module works against it unchanged:
test "provisions a sandbox per tenant" do
client = ExDaytona.Testing.client()
ExDaytona.Testing.expect(:post, "/sandbox", fn env ->
assert %{"labels" => %{"my-app/tenant" => "user-1"}} = JSON.decode!(env.body)
{200, ExDaytona.Testing.sandbox_json(%{id: "sb-1"})}
end)
# Sandbox.create polls state until "started" — stub the poll.
ExDaytona.Testing.stub(:get, "/sandbox/sb-1", ExDaytona.Testing.sandbox_json(%{id: "sb-1"}))
assert {:ok, sandbox} =
MyApp.Sandboxes.provision(client, tenant: "user-1")
assert ExDaytona.Sandbox.id(sandbox) == "sb-1"
ExDaytona.Testing.verify!()
endExpectations and stubs
expect/3queues a one-shot response consumed FIFO per{method, path};verify!/0fails the test if any remain.stub/3registers a reusable fallback (state polls, repeated lookups); stubs are consulted after expectations and never consumed.- A path matches when it equals the request path or is a suffix of
it — so
"/process/execute"matches the toolbox route"/{sandbox_id}/process/execute"without spelling out the prefix. - Responses:
{status, body}(maps/lists JSON-encoded, binaries sent raw), a bare body (status 200), a bare status,status:/body:/headers:keywords,{:error, reason}to simulate a transport failure, or a fun of theTesla.Envreturning any of these — use a fun to assert on the request itself.
Everything is keyed by the test process, so async: true tests don't
interfere; state is cleaned up when the test process exits. Requests
made by processes the SDK spawns internally (pollers, log followers)
resolve to the client's owner automatically.
Sandboxes without HTTP
Facades that operate on a sandbox (ExDaytona.FS,
ExDaytona.Session, ExDaytona.Git, ...) take a sandbox struct —
build one directly with sandbox/2 and skip the create flow entirely:
sandbox = ExDaytona.Testing.sandbox(%{id: "sb-1"})
ExDaytona.Testing.expect(:post, "/process/execute", %{exitCode: 0, result: "hi"})
assert {:ok, %{exit_code: 0}} = ExDaytona.Sandbox.exec(sandbox, "echo hi")Streaming
HTTP chunk streams (log follows, ExDaytona.FS transfers) and
websockets (ExDaytona.LogStream, ExDaytona.Pty,
ExDaytona.CodeInterpreter) bypass the Tesla adapter — script them
with script_http_stream/1 and script_ws/1:
ExDaytona.Testing.script_ws(frames: [{:binary, <<1, 1, 1>> <> "hello"}])
{:ok, stream} = ExDaytona.Session.open_log_stream(session, cmd_id)
assert {:ok, %{stdout: "hello"}} = ExDaytona.LogStream.collect(stream)
Summary
Functions
A ready-to-use ExDaytona.Client backed entirely by this process's
scripts: the Tesla adapter answers from expect/3/stub/3, and the
streaming transports from script_http_stream/1/script_ws/1.
Queue a one-shot response for the next method request whose path
matches path (exactly, or as a suffix — see the module docs).
Consumed FIFO per {method, path}; leftovers fail verify!/0.
A %ExDaytona.Sandbox{} struct for facades that operate on sandboxes,
with no HTTP involved in building it. Defaults: id "sandbox-test",
state "started", and a toolbox proxy URL — override any
ExDaytona.Model.Sandbox field via attrs (snake_case conveniences
:toolbox_proxy_url, :organization_id, :error_reason included).
Script the next chunked HTTP stream (build-log follows,
ExDaytona.FS uploads/downloads including ExDaytona.FS.stream!/3).
The script stays in effect until replaced.
Script the next websocket connection opened by a process this test
started (an ExDaytona.LogStream, ExDaytona.Pty, or
ExDaytona.CodeInterpreter run against a client/1). The script stays
in effect until replaced.
Register a reusable response for method/path — consulted whenever
no expectation matches, never consumed, never verified. The tool for
endpoints the SDK polls (sandbox state, snapshot state, command
status).
Raise ExDaytona.Testing.VerificationError unless every expectation
queued by this process was consumed. Call it at the end of the test.
Functions
@spec client(keyword()) :: ExDaytona.Client.t()
A ready-to-use ExDaytona.Client backed entirely by this process's
scripts: the Tesla adapter answers from expect/3/stub/3, and the
streaming transports from script_http_stream/1/script_ws/1.
Options are passed through to ExDaytona.Client.new!/1 (:base_url,
:middleware, ...); :api_key defaults to "dtn_test" and :retry
to false.
Queue a one-shot response for the next method request whose path
matches path (exactly, or as a suffix — see the module docs).
Consumed FIFO per {method, path}; leftovers fail verify!/0.
ExDaytona.Testing.expect(:get, "/sandbox/sb-1", ExDaytona.Testing.sandbox_json())
ExDaytona.Testing.expect(:delete, "/sandbox/sb-1", 200)
ExDaytona.Testing.expect(:post, "/snapshots", fn env ->
assert JSON.decode!(env.body)["name"] == "base"
{200, %{id: "snap-1", name: "base", state: "active"}}
end)
@spec sandbox(ExDaytona.Client.t() | map() | nil, map()) :: ExDaytona.Sandbox.t()
A %ExDaytona.Sandbox{} struct for facades that operate on sandboxes,
with no HTTP involved in building it. Defaults: id "sandbox-test",
state "started", and a toolbox proxy URL — override any
ExDaytona.Model.Sandbox field via attrs (snake_case conveniences
:toolbox_proxy_url, :organization_id, :error_reason included).
Pass a client as the first argument to bind the sandbox to it;
otherwise a fresh client/1 is built. sandbox(attrs) is accepted as
a shorthand for sandbox(nil, attrs).
A sandbox JSON body (string keys, camelCase — what the API sends) for
use in expect/3/stub/3 responses. Same defaults and snake_case
conveniences as sandbox/2.
@spec script_http_stream(keyword()) :: :ok
Script the next chunked HTTP stream (build-log follows,
ExDaytona.FS uploads/downloads including ExDaytona.FS.stream!/3).
The script stays in effect until replaced.
Options
:status— response status (default200):chunks— body chunks to deliver in order (default[]):chunk_delay— ms between chunks (default0):headers— response headers (default[]):error— after the chunks, fail the transport with this reason instead of completing
@spec script_ws(keyword()) :: :ok
Script the next websocket connection opened by a process this test
started (an ExDaytona.LogStream, ExDaytona.Pty, or
ExDaytona.CodeInterpreter run against a client/1). The script stays
in effect until replaced.
Options
:frames— frames to replay, as{:binary, data}/{:text, data}tuples (log streams use 3-byte channel markers —<<1, 1, 1>>stdout,<<2, 2, 2>>stderr; unmarked bytes surface as:output):frame_delay— ms between frames (default0):close_reason— how the connection closes after the frames (default:normal; e.g.{:error, :closed}for a drop):hold_open— keep the connection open after the frames instead of closing (defaultfalse):connect— set to{:error, %ExDaytona.Error{...}}to fail the upgrade itself
Register a reusable response for method/path — consulted whenever
no expectation matches, never consumed, never verified. The tool for
endpoints the SDK polls (sandbox state, snapshot state, command
status).
@spec verify!() :: :ok
Raise ExDaytona.Testing.VerificationError unless every expectation
queued by this process was consumed. Call it at the end of the test.