Testing API Reference¶
Reference for the test runners, the test result, operation accessors, and the enums used across the testing SDKs. For an onboarding walkthrough, start with Authoring.
LocalDurableTestRunner¶
Runs a durable handler in-process against an in-memory checkpoint store. No AWS credentials, no deployment, and no containers are required. Use it for unit tests and CI.
Create a runner¶
Call LocalDurableTestRunner.setupTestEnvironment() in beforeAll and
LocalDurableTestRunner.teardownTestEnvironment() in afterAll.
static setupTestEnvironment(params?: LocalDurableTestRunnerSetupParameters): Promise<void>
static teardownTestEnvironment(): Promise<void>
Constructor parameters:
handlerFunction(required) The handler created withwithDurableExecution.
Use as a context manager. The context manager starts the scheduler thread on entry and stops it on exit.
Parameters:
handler(required) The handler decorated with@durable_execution.poll_interval(optional) Seconds between internal polling cycles. Defaults to1.0.
// Class-based input type
LocalDurableTestRunner.create(Class<I> inputType, BiFunction<I, DurableContext, O> handlerFn)
// TypeToken-based input type (for generic types)
LocalDurableTestRunner.create(TypeToken<I> inputType, BiFunction<I, DurableContext, O> handlerFn)
// With a custom DurableConfig
LocalDurableTestRunner.create(Class<I> inputType, BiFunction<I, DurableContext, O> handlerFn, DurableConfig config)
// From a DurableHandler instance (extracts config automatically)
LocalDurableTestRunner.create(Class<I> inputType, DurableHandler<I, O> handler)
Override config or the output type on an existing runner:
LocalDurableTestRunner<I, O> withDurableConfig(DurableConfig config)
LocalDurableTestRunner<I, O> withOutputType(Class<O> outputType)
LocalDurableTestRunner<I, O> withOutputType(TypeToken<O> outputType)
Parameters:
inputType(required) The input type class orTypeToken.handlerFn(required) ABiFunction<I, DurableContext, O>implementing the handler logic.config(optional) ADurableConfig. The runner overrides theDurableExecutionClientwith its in-memory implementation but preserves all other settings such as customSerDes.handler(optional) ADurableHandlerinstance. The runner extracts its configuration automatically.
Construction is the whole lifecycle. The opts are the production handler's
durable.HandlerOption values, such as durable.WithSerdes. The handler has type
durable.Handler[I, O], that is func(durable.Context, I) (O, error).
func NewLocalRunner[I, O any](handler durable.Handler[I, O], opts ...durable.HandlerOption) *LocalRunner[I, O]
Parameters:
handler(required) The durable handler under test.opts(optional)durable.HandlerOptionvalues, applied as in production. The runner supplies its own in-memory execution client.
Returns: *LocalRunner[I, O]
durable::test_util::LocalRunner runs the handler in process. The test_util
module requires the SDK's test-util cargo feature, which you enable on a
dev-dependency as Install the testing SDK
shows.
impl LocalRunner {
// ...
pub fn new() -> Self;
// ...
pub fn options(mut self, options: crate::Options) -> Self;
// ...
}
LocalRunner::default() returns the same runner as LocalRunner::new(). Each
setting is a builder method that takes the runner and returns it (see
LocalDurableTestRunnerSetupParameters).
You pass the handler to run, so one runner can run several handlers. The runner
needs no setup or teardown.
Parameters:
options(optional) The production handler'sdurable::Options. Every setting applies as in production except the transport. The runner ignoressdk_config,lambda_client, andexecution_client, and uses its in-memory backend. Defaults toOptions::default().
Returns: LocalRunner
The local runner type is DurableTestRunner<TInput, TOutput>. Construct it with the
workflow delegate and dispose it with await using:
DurableTestRunner(
Func<TInput, IDurableContext, Task<TOutput>> handler,
TestRunnerOptions? options = null)
await using var runner = new DurableTestRunner<object?, string>(
Workflow,
new TestRunnerOptions { SkipTime = true });
There is no static setup/teardown. The await using declaration and DisposeAsync()
replace the TypeScript setupTestEnvironment/teardownTestEnvironment lifecycle.
Constructor parameters:
handler(required) AFunc<TInput, IDurableContext, Task<TOutput>>, either a method group or an inline(input, ctx) => ...async lambda.options(optional) ATestRunnerOptionsrecord. Defaults to a new instance withSkipTime = true.
Run the handler¶
run() drives the full replay loop until the execution completes or fails.
Parameters:
params(optional) AnInvokeRequestobject.payload(optional) The input payload to pass to the handler.
Returns: Promise<TestResult>
run(
input: str | None = None,
timeout: int = 900,
function_name: str = "test-function",
execution_name: str = "execution-name",
account_id: str = "123456789012",
) -> DurableFunctionTestResult
Parameters:
input(optional) JSON-encoded input string. Defaults toNone.timeout(optional) Maximum seconds to wait for completion. Defaults to900.function_name(optional) Function name used internally. Defaults to"test-function".execution_name(optional) Execution name used internally. Defaults to"execution-name".account_id(optional) Account ID used internally. Defaults to"123456789012".
Returns: DurableFunctionTestResult
Raises: TimeoutError if the execution does not complete within timeout.
// Runs a single invocation (may return PENDING)
TestResult<O> run(I input)
// Drives the full replay loop until SUCCEEDED, FAILED, or PENDING with no
// auto-advanceable operations
TestResult<O> runUntilComplete(I input)
Parameters:
input(required) The handler input.
Returns: TestResult<O>
Run performs a single invocation and may return PENDING.
RunUntilComplete loops, advancing timers and running registered invoke
targets between invocations. It returns at a terminal status, when the
execution is blocked on a callback or chained invoke, or at the invocation
cap.
func (r *LocalRunner[I, O]) Run(event I) (*TestResult, error)
func (r *LocalRunner[I, O]) RunUntilComplete(event I, opts ...RunnerOption) (*TestResult, error)
Parameters:
event(required) The handler input, of the runner's input typeI. The runner encodes it as JSON.opts(optional)RunnerOptionvalues.WithMaxInvocationssets the invocation cap, which defaults toDefaultMaxInvocations(100).
Returns: *TestResult and an error. When RunUntilComplete reaches the cap,
the result holds the last state, usually PENDING, with CapReached set to true.
Errors: The error is non-nil, and the result nil, only when the runner itself
fails. That happens when the event does not encode as JSON, the invocation returns
an error that indicates an SDK or runner bug, or the response does not parse. A
handler that returns an error produces a nil error and a result with status
FAILED.
pub fn run<E, O, F, Fut>(&self, handler: F, event: E) -> RunHandle<O>
where
E: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
F: Fn(E, DurableContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O, BoxError>> + Send;
// ...
pub fn max_invocations(mut self, max: usize) -> Self;
run returns a durable::test_util::RunHandle<O> at once. RunHandle is a future.
Awaiting it drives the full replay loop until the execution succeeds or fails,
nothing can advance it, or the invocation cap is reached. The run starts when the
test first awaits a future of the run, such as the RunHandle.
Parameters:
handler(required) The durable handler, the function you pass todurable::run. Its event and output types needSerializeandDeserializeOwned.event(required) The handler input. The runner encodes it as JSON.max(optional) The invocation cap, set withmax_invocations. Defaults to 100. A value of 0 counts as 1.
Returns: RunHandle<O>. Awaiting it returns
Result<TestResult<O>, TestRunnerError>. When nothing can advance the execution, or
the cap is reached, the result has status ExecutionStatus::Running and
error_message() says why the run ended.
Errors: Awaiting the handle returns Err(TestRunnerError) only when the runner
itself fails. That happens when the event does not encode as JSON
(EventSerialization), the output of a succeeded execution does not decode into O
(OutputDeserialization), or a response envelope has a status other than
SUCCEEDED, FAILED, and PENDING (UnexpectedEnvelope). A handler that returns
an error produces Ok with a result whose status is ExecutionStatus::Failed.
Task<TestResult<TOutput>> RunAsync(
TInput input,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
RunAsync drives the full replay loop until the workflow reaches a terminal state. It
throws InvalidOperationException if the workflow suspends on a callback. Use the
StartAsync + WaitForCallbackAsync two-call pattern for callback workflows.
Parameters:
input(required) The handler input.timeout(optional) Wall-clock timeout for the call. Defaults toTestRunnerOptions.DefaultTimeout(30 seconds).cancellationToken(optional) ACancellationToken.
Returns: Task<TestResult<TOutput>>
Run asynchronously¶
Not applicable. run() already drives the full replay loop.
# Start execution without waiting for completion
run_async(
input: str | None = None,
timeout: int = 900,
function_name: str = "test-function",
execution_name: str = "execution-name",
account_id: str = "123456789012",
) -> str # returns execution_arn
# Wait for a running execution to complete
wait_for_result(execution_arn: str, timeout: int = 60) -> DurableFunctionTestResult
Not applicable on the local runner. run() runs a single invocation,
runUntilComplete() drives the full loop. The cloud runner exposes startAsync(), see
CloudDurableTestRunner.
For a callback or chained invoke, run to PENDING, resolve the pending operation,
then call RunUntilComplete again.
run returns the RunHandle at once and drives nothing until the test awaits a
future of the run. Before you await the RunHandle, take operation handles from it
and await their controls, such as wait_for_data and send_callback (see Drive a
callback from an operation). The run moves on
only while the test awaits a future of the run, such as the RunHandle or
wait_for_data. So await the operation controls first and the RunHandle last.
The local runner supports the async pattern, primarily for callback workflows:
// Start the workflow and return the durable execution ARN
Task<string> StartAsync(
TInput input, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
// Drive a started workflow to a terminal state
Task<TestResult<TOutput>> WaitForResultAsync(
string durableExecutionArn, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
StartAsync drives the workflow to its first suspension point and returns the ARN.
WaitForResultAsync must be preceded by a StartAsync on the same runner; it throws
InvalidOperationException otherwise. Use these together with WaitForCallbackAsync
and the SendCallback* methods to drive callback workflows.
Inspect operations¶
getOperation(name: string, index?: number): DurableOperation
getOperationByIndex(index: number): DurableOperation
getOperationByNameAndIndex(name: string, index: number): DurableOperation
getOperationById(id: string): DurableOperation
Parameters:
name(required) The operation name.index(optional) Zero-based index when multiple operations share the same name. Defaults to0.id(required) The unique operation ID.
Returns: DurableOperation
Not available on the runner. Inspect operations through the result:
result.get_step(name), result.get_wait(name), result.get_callback(name),
result.get_context(name), result.get_invoke(name),
result.get_operation_by_name(name), and result.get_all_operations(). See
TestResult.
Parameters:
name(required) The operation name.
Returns: TestOperation, or null if not found.
Inspect operations through the result.
func (r *TestResult) Operation(name string) *TestOperation
func (r *TestResult) OperationByNameAndIndex(name string, index int) *TestOperation
func (r *TestResult) OperationByIndex(index int) *TestOperation
func (r *TestResult) OperationByID(id string) *TestOperation
func (r *TestResult) OperationsByType(opType string) []TestOperation
Parameters:
name(required) The operation name.Operationreturns the first match.index(required) A zero-based index. ForOperationByNameAndIndexit counts operations with that name. ForOperationByIndexit counts all operations.id(required) The operation's wire ID.opType(required) An operation type string, such as"STEP".
Returns: A *TestOperation, or nil if not found. OperationsByType returns a
[]TestOperation.
Take operation handles from the RunHandle that run returns. A handle names an
operation by name, index, or ID, and finds it each time you use the handle. So you
can take a handle before the operation exists. After the run,
result.operation(name) and result.operations_named(name) find operations by
name, result.children(parent) finds the children of an operation, and
result.operations() returns the whole history in first-checkpoint order (see
Operations).
pub fn operation(&self, name: &str) -> OperationHandle;
// ...
pub fn operation_by_index(&self, index: usize) -> OperationHandle;
// ...
pub fn operation_by_name_and_index(&self, name: &str, index: usize) -> OperationHandle;
// ...
pub fn operation_by_id(&self, id: &str) -> OperationHandle;
// ...
pub fn snapshot(&self) -> Option<TestOperation>;
Parameters:
name(required) The operation name, matched exactly.operationnames the first operation with that name.index(required) A zero-based index, inTestResult::operationsorder. Foroperation_by_name_and_indexit counts operations with that name. Foroperation_by_indexit counts all operations.id(required) The unique operation ID.
Returns: An OperationHandle. Its snapshot() returns the operation as it is
now, as a TestOperation, or None while no operation matches.
The .NET runner does not expose operation lookups; inspect operations through the
returned TestResult<TOutput> instead:
TestStep GetStep(string name) // throws if not found
TestStep? FindStep(string name) // null if not found
IReadOnlyList<TestStep> GetSteps(string name)
TestStep GetStepById(string operationId)
IReadOnlyList<TestStep> GetStepsByStatus(string status)
IReadOnlyList<TestStep> GetChildren(TestStep parent)
result.Steps also exposes the full list of recorded operations (excluding the
top-level execution). See TestResult.
Drive callbacks¶
Callback interaction is on the DurableOperation object. Get a handle with
getOperation() and then call waitForData() and sendCallback*() on it. See
Drive a callback from an operation.
# Wait for a callback to become available and return its ID
wait_for_callback(execution_arn: str, name: str | None = None, timeout: int = 60) -> str
# Send a successful callback result
send_callback_success(callback_id: str, result: bytes | None = None) -> None
# Send a callback failure
send_callback_failure(callback_id: str, error: ErrorObject | None = None) -> None
# Send a callback heartbeat
send_callback_heartbeat(callback_id: str) -> None
// Get the callback ID for a named callback operation
String getCallbackId(String operationName)
// Complete a callback with a success result
void completeCallback(String callbackId, String result)
// Fail a callback
void failCallback(String callbackId, ErrorObject error)
// Time out a callback
void timeoutCallback(String callbackId)
Drive callbacks from the runner by callback ID. Enumerate the pending callbacks with
OpenCallbacks, then resolve one. A failure takes an errorType and an
errorMessage string.
func (r *LocalRunner[I, O]) OpenCallbacks() []OpenCallback
func (r *LocalRunner[I, O]) SendCallbackSuccess(callbackID string, payload any) error
func (r *LocalRunner[I, O]) SendCallbackFailure(callbackID, errorType, errorMessage string) error
func (r *LocalRunner[I, O]) SendCallbackHeartbeat(callbackID string) error
func (r *LocalRunner[I, O]) TimeoutCallback(callbackID string) error
type OpenCallback struct {
CallbackID string
Name string
}
SendCallbackSuccess encodes payload as JSON. So a Go string "approved" arrives
as "approved" with its quote characters.
OpenCallback.Name is the callback operation's name. A WaitForCallback records an
unnamed inner callback, so its Name is empty. The WaitForCallback name is on the
callback's parent operation.
Answer a callback in one of two ways. Queue a reply on the runner before the run
with callback or callback_for, or send one during the run from an operation
handle (see Drive a callback from an
operation). Both take a
durable::test_util::CallbackReply.
pub fn callback(mut self, reply: CallbackReply) -> Self;
// ...
pub fn callback_for(mut self, name: impl Into<String>, reply: CallbackReply) -> Self;
A pending callback is a callback that waits for a reply. Between invocations, the
runner visits the pending callbacks in the order they were first checkpointed. It
gives each one the first queued callback_for reply that names it, else the first
queued callback reply, else nothing. A name matches the callback's own name, or
the name of the wait_for_callback that contains it. Queued replies arrive before
the virtual clock moves. When the run ends because nothing can advance the
execution, the result has status Running, and its error_message() names each
queued callback_for reply that matched no callback.
impl CallbackReply {
pub fn success<T: Serialize>(value: &T) -> Result<Self, BoxError>;
// ...
pub fn raw(payload: impl Into<String>) -> Self;
// ...
pub fn failure(failure: CallbackFailure) -> Self;
// ...
pub fn timeout() -> Self;
// ...
pub fn heartbeat_timeout() -> Self;
// ...
}
success(value)The callback succeeds with the JSON encoding ofvalue. Returns an error whenvaluedoes not encode as JSON.raw(payload)The callback succeeds withpayloadunchanged. The handler decodes it with the callback's serdes.failure(failure)The callback fails with the error fields offailure.timeout()The callback times out with the messageCallback timed out.heartbeat_timeout()The callback times out with the error typeCallback.Heartbeatand the messageCallback timed out on heartbeat.
impl CallbackFailure {
pub fn new(error_type: impl Into<String>, error_message: impl Into<String>) -> Self;
// ...
pub fn with_error_data(mut self, error_data: impl Into<String>) -> Self;
// ...
pub fn with_stack_trace(mut self, frames: Vec<String>) -> Self;
// ...
}
CallbackFailure::new sets the error type and message. with_error_data and
with_stack_trace add the error data and the stack trace frames.
CallbackFailure::default() leaves every field unset.
Callbacks are driven from the runner. After StartAsync, wait for the callback ID, then
send success, failure, or a heartbeat:
// Wait for a pending callback and return its ID
Task<string> WaitForCallbackAsync(
string durableExecutionArn, string? name = null,
TimeSpan? timeout = null, CancellationToken cancellationToken = default)
// Complete a callback with a success result
Task SendCallbackSuccessAsync<TResult>(
string callbackId, TResult result, CancellationToken cancellationToken = default)
// Fail a callback
Task SendCallbackFailureAsync(
string callbackId, ErrorObject? error = null, CancellationToken cancellationToken = default)
// Keep a callback alive
Task SendCallbackHeartbeatAsync(
string callbackId, CancellationToken cancellationToken = default)
There is no separate "time out" method: to simulate a timeout, fail the callback with an
ErrorObject or let the configured callback timeout elapse.
Drive chained invokes¶
Not applicable. Register a mock handler for the invoked function and let the test drive the real handler path. See Register mock handlers for invoke.
Not applicable.
// Complete a chained invoke with a success result
void completeChainedInvoke(String name, String result)
// Fail a chained invoke
void failChainedInvoke(String name, ErrorObject error)
// Time out a chained invoke
void timeoutChainedInvoke(String name)
// Stop a chained invoke
void stopChainedInvoke(String name, ErrorObject error)
Resolve a chained invoke by its operation name, or register the target so the invoke runs for real (see the next section).
Register a target for the invoked function. See Register mock handlers for
invoke. An invoke of a function name with no
registered target fails the execution, and the result's error_message() names the
function.
The .NET SDK does not expose per-invoke completion methods. Instead, register the invoked function's handler on the runner and let the real invoke path run against it. See Register mock handlers for invoke. To make an invoke fail, throw from the registered handler.
Register mock handlers for invoke¶
// Register a durable function for context.invoke() calls
registerDurableFunction(functionName: string, handler: DurableLambdaHandler): this
// Register a standard Lambda function for context.invoke() calls
registerFunction(functionName: string, handler: Handler): this
Both methods return the runner for chaining.
Not applicable.
Not applicable.
Register the function identifier the handler passes to durable.Invoke.
Build the target with DurableFunction for a durable handler or
PlainFunction for a one-shot handler. Registered targets may invoke other
registered identifiers up to MaxInvokeDepth levels deep.
pub fn register_function<I, O, F, Fut>(mut self, name: impl Into<String>, function: F) -> Self
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
F: Fn(I) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O, BoxError>> + Send + 'static;
// ...
pub fn register_durable_function<E, O, F, Fut>(
mut self,
name: impl Into<String>,
handler: F,
) -> Self
where
E: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
F: Fn(E, DurableContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<O, BoxError>> + Send;
Both methods return the runner for chaining. name is the function name the handler
passes to ctx.invoke, matched exactly. A later registration under the same name
replaces the earlier one. When an invocation suspends, the runner runs the target of
each pending invoke once, before the virtual clock moves.
register_function registers a plain function. The runner decodes its input from
the invoke payload and encodes its output as JSON. An Err fails the invoke.
register_durable_function runs the handler as its own durable execution, with a
fresh in-memory backend and a copy of the runner's settings. The runner gives that
execution none of its registered functions, queued callback replies, or injected
checkpoint failures. So a registered durable target cannot invoke another registered
target.
// Register a durable sibling that the workflow invokes with context.InvokeAsync()
DurableTestRunner<TInput, TOutput> RegisterDurableFunction<TPayload, TResult>(
string functionNameOrArn,
Func<TPayload, IDurableContext, Task<TResult>> handler)
// Register a plain (non-durable) Lambda sibling, also invoked with context.InvokeAsync()
DurableTestRunner<TInput, TOutput> RegisterFunction<TPayload, TResult>(
string functionNameOrArn,
Func<TPayload, ILambdaContext, Task<TResult>> handler)
Both methods return the runner for chaining.
Simulate failures¶
Not applicable.
Not applicable.
To exercise failure and retry, return an error from a step. OmitTokenOnCheckpoint
withholds the checkpoint token on the n-th checkpoint call, so the invocation ends
PENDING and resumes on the next run. n counts calls from now, starting at 1. A
value below 1 panics.
These builder methods make the in-memory backend fail or delay checkpoint calls.
pub fn fail_next_checkpoints(self, count: usize) -> Self;
// ...
pub fn fail_checkpoints_after(mut self, skip: usize, count: usize) -> Self;
// ...
pub fn fail_checkpoints_after_retryable(mut self, skip: usize, count: usize) -> Self;
// ...
pub fn withdraw_checkpoints_after(mut self, n: usize) -> Self;
// ...
pub fn checkpoint_latency(mut self, latency: std::time::Duration) -> Self;
fail_next_checkpoints(count)Rejects the nextcountcheckpoint calls with a non-retryable error and stores nothing for them. The SDK sends no further checkpoint and fails the execution with the error typeCheckpointUnrecoverableExecutionError.fail_checkpoints_after(skip, count)Letsskipcheckpoint calls through, then rejects the nextcountcalls in the same way.fail_checkpoints_after_retryable(skip, count)Letsskipcheckpoint calls through, then rejects the nextcountcalls with a retryable error. The invocation fails, the runner invokes again, and replay resumes from the stored state.withdraw_checkpoints_after(n)Answers checkpoint requestn + 1without a checkpoint token, once. Requests count from 1 across invocations. The backend applies that request, the SDK sends nothing more in that invocation, and the runner invokes again at once.checkpoint_latency(latency)Delays each checkpoint response bylatencyin Tokio time, to expose races. Defaults to zero. It does not move the virtual clock.
One checkpoint fault applies at a time. Each fail_* call replaces the fault that
an earlier fail_* call set, so fail_next_checkpoints(count) is the same as
fail_checkpoints_after(0, count).
The .NET SDK does not expose checkpoint-manipulation methods. To exercise failure and
retry paths, throw from inside a step and assert on TestStep.Attempt and the recorded
operation status. See the retry example in Authoring.
Control time¶
Pass { skipTime: true } to setupTestEnvironment(). Both context.wait() delays and
step retry delays complete in zero wall-clock time.
Pass skip_time=True to DurableFunctionTestRunner. The runner uses a virtual clock
that skips modeled delays instantly. Both context.wait() durations and step retry
delays complete in zero wall-clock time. skip_time defaults to True.
Set skip_time=False to assert on real wait durations.
runUntilComplete() calls advanceTime() after each invocation. When you call run()
directly, call advanceTime() yourself between invocations. advanceTime() marks
PENDING step retries as READY and completes STARTED waits without real-time sleeps.
Time is virtual and always skipped. RunUntilComplete advances timer-blocked
operations between invocations. CompletePendingTimers fires every pending retry
and wait timer at once and reports whether any fired. To check a wait's duration,
read WaitDetails.WaitSeconds.
Time is virtual and always skipped. The runner keeps a virtual clock in whole
seconds that starts at 0 for each run. Between invocations, it moves the clock to
the earliest pending wait or retry end and fires every wait and retry due by then,
so waits and retries end in time order. Time moves only between invocations. To
check a wait's duration, read WaitDetails::duration (see Wait
details).
Set SkipTime on TestRunnerOptions. When true (the default), both WaitAsync delays
and step/WaitForConditionAsync retry backoffs complete immediately instead of sleeping
for real wall-clock time. There is no manual advanceTime() call. RunAsync advances
time internally.
await using var runner = new DurableTestRunner<object, string>(
Workflow,
new TestRunnerOptions { SkipTime = true });
Set SkipTime = false to assert on real wait durations.
See Authoring: Skip time in tests for an overview.
Reset between runs¶
Clears the operation index, wait manager, and operation storage so the runner can be reused across tests.
Create a new DurableFunctionTestRunner instance per test.
Create a new LocalDurableTestRunner instance per test.
Reset discards the checkpoint log, the recorded events, and every open
callback and invoke, while keeping the handler, its options, and the
registered functions, so one runner serves several cases.
A runner holds only settings, and run borrows it. Each run starts from an empty
history and gets its own copy of the queued callback replies, so one runner serves
several cases with no reset.
There is no reset(). A runner is single-use and not thread-safe: create a new
DurableTestRunner<TInput, TOutput> per test and dispose it with await using.
Reference types¶
LocalDurableTestRunnerSetupParameters¶
Fields:
skipTime(optional) Install fake timers so retry delays and waits complete instantly. Defaults tofalse.checkpointDelay(optional) Simulated delay in milliseconds on checkpoint API calls. Useful for surfacing race conditions.
Fields:
skip_time(optional) Use a virtual clock that skips modeled delays instantly. Defaults toTrue.poll_interval(optional) Seconds between internal polling cycles. Defaults to1.0.
Not applicable. Configure with withDurableConfig, withOutputType, and
advanceTime() on the runner instance.
The only run option is WithMaxInvocations on RunUntilComplete. Handler options
come from durable.HandlerOption at construction. At the cap, RunUntilComplete
returns its last result with CapReached set to true.
Configure the runner with builder methods on LocalRunner. Each takes the runner
and returns it. These methods set how the in-memory backend pages the execution
state:
pub fn single_page(mut self) -> Self;
// ...
pub fn initial_page_size(mut self, size: usize) -> Self;
// ...
pub fn checkpoint_page_size(mut self, size: usize) -> Self;
Methods:
options(options)The production handler'sdurable::Options. Defaults toOptions::default(). See Create a runner.max_invocations(max)The invocation cap. Defaults to 100. See Run the handler.single_page()Serves the whole execution state in one page. By default the backend pages the state, as the service does.initial_page_size(size)The number of operations in the first page of state that each invocation receives. Defaults to 1. A value of 0 counts as 1.checkpoint_page_size(size)The number of stored operations above which a checkpoint response is paged. Defaults to 1. A value of 0 counts as 1.checkpoint_latency(latency)The delay of each checkpoint response, in Tokio time. Defaults to zero. See Simulate failures.fail_next_checkpoints,fail_checkpoints_after,fail_checkpoints_after_retryable, andwithdraw_checkpoints_afterInjected checkpoint failures. None by default. See Simulate failures.callbackandcallback_forQueued callback replies. None by default. See Drive callbacks.register_functionandregister_durable_functionInvoke targets. None by default. See Register mock handlers for invoke.
Time is always virtual. See Control time.
Configuration lives in the TestRunnerOptions record passed to the constructor:
public sealed record TestRunnerOptions
{
public bool SkipTime { get; init; } = true;
public int MaxInvocations { get; init; } = 100;
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
public ILambdaSerializer? Serializer { get; init; }
public ILoggerFactory? LoggerFactory { get; init; }
public string DurableExecutionArn { get; init; } // synthetic test ARN by default
}
Fields:
SkipTime(optional) Complete waits and retry backoffs instantly. Defaults totrue(the opposite of the JavaScript SDK, where time-skipping is opt-in).MaxInvocations(optional) Maximum handler invocations before throwingTestExecutionLimitException. Defaults to100.DefaultTimeout(optional) Wall-clock timeout perRunAsync/WaitForResultAsynccall. Defaults to 30 seconds.Serializer(optional) AnILambdaSerializerfor result deserialization. Defaults toDefaultLambdaJsonSerializer.LoggerFactory(optional) AnILoggerFactoryfor runtime logging.DurableExecutionArn(optional) The ARN used in the test context. Defaults to a synthetic test ARN.
TestResult¶
The object returned by run(). Exposes the execution status, the final result, any
error, and the full operation history.
Status¶
Returns: ExecutionStatus (SUCCEEDED, FAILED, PENDING, or undefined).
Type: InvocationStatus (SUCCEEDED, FAILED, PENDING, TIMED_OUT, STOPPED).
Returns: ExecutionStatus (SUCCEEDED, FAILED, PENDING).
Read the Status field. It is one of three ExecutionStatus values. Compare it
against the constants, for example result.Status == durabletest.Succeeded.
impl<O> TestResult<O> {
// ...
pub fn status(&self) -> ExecutionStatus;
// ...
pub fn is_success(&self) -> bool;
// ...
pub fn is_failure(&self) -> bool;
// ...
pub fn is_suspended(&self) -> bool;
// ...
}
Returns: status() returns a durable::test_util::ExecutionStatus (see
Execution status). is_success() is true for Succeeded.
is_failure() is true for Failed, TimedOut, and Stopped. is_suspended()
is true for Running. Under LocalRunner, a run that ends because nothing can
advance the execution, or because it reached the invocation cap, has status
Running.
InvocationStatus Status { get; }
bool IsSucceeded { get; } // Status == InvocationStatus.Succeeded
bool IsFailed { get; } // Status == InvocationStatus.Failed
Type: InvocationStatus (Succeeded, Failed, Pending). There is no separate
execution-status enum in .NET; the cloud runner maps the service's finer terminal states
(FAILED, TIMED_OUT, STOPPED) onto InvocationStatus.Failed.
Result¶
Returns: The deserialized execution output, or undefined if not available.
Throws: The execution error if the execution failed.
<T> T getResult(Class<T> resultType)
<T> T getResult(TypeToken<T> resultType)
O getResult() // requires withOutputType() to be set
Returns: The deserialized execution output.
Throws: IllegalStateException if the execution did not succeed.
RawResult holds the JSON result when the status is SUCCEEDED. Deserialize
it with the package generic ResultAs[O], which returns an error if the
execution did not succeed.
Returns: The handler output, already decoded into the handler's output type O,
when the status is Succeeded, and None otherwise. output() borrows it and
into_output() takes it.
Errors: When the output of a succeeded execution does not decode into O,
awaiting the RunHandle returns Err(TestRunnerError::OutputDeserialization).
The deserialized execution output when Status is InvocationStatus.Succeeded;
otherwise the default value for TOutput. The result type is fixed at the runner's
TOutput type parameter, so no per-call type argument is needed. Call
EnsureSucceeded() first if you want a failed run to throw before you read Result.
Error¶
Error holds a *TestError when the status is FAILED. This is
durabletest's own error type, not the SDK's durable.ErrorObject.
pub fn error(&self) -> Option<&crate::WireError>;
// ...
pub fn error_type(&self) -> Option<&str>;
// ...
pub fn error_message(&self) -> Option<&str>;
Returns: error() returns the error as a durable::WireError (see
TestResultError), or None when the execution succeeded. When
the execution failed, it is the recorded error. While the status is Running, it
says why the run ended. error_type() and error_message() read the same record.
Under LocalRunner, a failure recorded with no error type reads Error.
Operations¶
Parameters:
params(optional) Filter byOperationStatus.
Returns: Array of DurableOperation.
result.operations # list[Operation], top-level only
result.get_all_operations() # list[Operation], including nested
# Typed lookup by name
result.get_step(name: str) -> StepOperation
result.get_wait(name: str) -> WaitOperation
result.get_context(name: str) -> ContextOperation
result.get_callback(name: str) -> CallbackOperation
result.get_invoke(name: str) -> InvokeOperation
result.get_execution(name: str) -> ExecutionOperation
result.get_operation_by_name(name: str) -> Operation
Raises: DurableFunctionsTestError if the operation is not found.
List<TestOperation> getOperations()
List<TestOperation> getSucceededOperations()
List<TestOperation> getFailedOperations()
TestOperation getOperation(String name)
getOperation(name) returns null if not found.
Operations lists every operation the execution checkpointed, in checkpoint order.
It includes nested operations and excludes the execution operation. A nested
operation's ParentID names its parent. To filter by status, iterate and compare
Status. OperationsByType filters by type string.
pub fn operations(&self) -> &[TestOperation];
// ...
pub fn operation(&self, name: &str) -> Option<&TestOperation>;
// ...
pub fn operations_named<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a TestOperation> + 'a;
// ...
pub fn children<'a>(
&'a self,
parent: &'a TestOperation,
) -> impl Iterator<Item = &'a TestOperation> + 'a;
operations() lists every operation the execution checkpointed, in first-checkpoint
order. It includes nested operations and excludes the execution operation. To filter
by status, filter the slice, for example
result.operations().iter().filter(|op| op.status() == durable::OperationStatus::Succeeded).
Parameters:
name(required) The operation name, matched exactly.operationreturns the first match, andoperations_namedreturns every match.parent(required) An operation of this result.childrenreturns the operations whose parent isparent.
Returns: A slice or an iterator of TestOperation. operation returns None
when no operation has the name.
IReadOnlyList<TestStep> Steps { get; } // all operations except the top-level execution
TestStep GetStep(string name) // throws if not found
TestStep? FindStep(string name) // null if not found
IReadOnlyList<TestStep> GetSteps(string name) // all matches (parallel branches, map items)
TestStep GetStepById(string operationId)
IReadOnlyList<TestStep> GetStepsByStatus(string status) // pass OperationStatus constants
IReadOnlyList<TestStep> GetChildren(TestStep parent)
Filter Steps by kind with LINQ, e.g.
result.Steps.Where(s => s.Kind == OperationKind.Step). Use the OperationStatus
string constants (e.g. OperationStatus.Succeeded) with GetStepsByStatus.
History events¶
Not available on the test result.
Events is the execution's history event sequence. EventTypes returns the
event type names in order. The local runner synthesizes the events and leaves
fields it has no source for unset.
Event is the service model type durable::lambda_types::Event, and
event.event_type() returns its durable::lambda_types::EventType. CloudRunner
returns the events of GetDurableExecutionHistory unchanged. LocalRunner
synthesizes the events with the same service model types and order, with one
InvocationCompleted event for each invocation. LocalRunner moves time only
between invocations, so a local run can take more invocations, and record more
InvocationCompleted events, than the same execution takes on Lambda.
TestOperation::events() returns the events of one operation (see Common
accessors).
The .NET TestResult<TOutput> does not expose raw history events. Assert on the folded
operations through Steps and the TestStep accessors instead. (Internally the cloud
runner reconstructs operations from the history event stream, but that stream is not
surfaced on the result.)
Invocations¶
Useful for asserting on the number of Lambda re-invocations the test runner drove.
Not available on the test result.
Not available on the test result.
Invocations records one entry per completed invocation. Both runners
populate it, so a cross-backend test can assert on len(result.Invocations).
invocations() holds one TestInvocation for each InvocationCompleted history
event, in history order. Under LocalRunner, invocation n, counting from 1, has
the request ID local-invocation-{n}, and the list has invocation_count()
records. Under CloudRunner, invocation_count() counts the InvocationCompleted
events, or the number of polls when the history holds no such event.
impl TestInvocation {
// ...
pub fn start_time(&self) -> Option<std::time::SystemTime>;
// ...
pub fn end_time(&self) -> Option<std::time::SystemTime>;
// ...
pub fn request_id(&self) -> Option<&str>;
// ...
pub fn error(&self) -> Option<&crate::WireError>;
// ...
}
When an InvocationCompleted event omits its details, start_time(), end_time(),
and request_id() return None. A time also returns None when its timestamp lies
outside the range of SystemTime. error() is None for an invocation that
returned a SUCCEEDED or PENDING result. For a FAILED result it holds the
result's error. Under LocalRunner, an invocation fault, such as a retryable
checkpoint failure, also sets error(). Its error type is None, and its message
is the fault's text.
Pretty-print¶
Writes a formatted table of operations to stdout.
Not applicable.
Not applicable.
FormatTree renders the operations as an indented tree. WriteTree writes the same
text to an io.Writer. Both take optional columns, and DefaultTreeColumns returns
the default set.
TestResult implements Display, which prints a table of the operations with every
column, so println!("{result}") writes it to stdout. display_with prints only
the columns that columns enables.
pub struct PrintColumns {
pub parent_id: bool,
pub name: bool,
pub kind: bool,
pub sub_type: bool,
pub status: bool,
pub start_time: bool,
pub end_time: bool,
pub duration: bool,
}
PrintColumns::default() enables every column. Set fields to false to hide
columns. The struct is #[non_exhaustive], so code outside the SDK builds it with
default(). The columns, in order, are parentId, name, type, subType,
status, startTime, endTime, and duration. The fields parent_id, name,
kind, sub_type, status, start_time, end_time, and duration enable them.
The table has a header line and one line per operation, with the cells separated by
| and a space on each side. Times print as ISO 8601 UTC with three fractional
digits. The duration prints in whole milliseconds, such as 1000ms. An absent value
prints -. A result with no operations prints No operations found.
The .NET SDK does not expose a pretty-print method on the result. Iterate result.Steps
and format them yourself, e.g. with a foreach over Name, Kind, and Status.
Reference types¶
TestResultError¶
Uses ErrorObject from the main SDK:
Uses ErrorObject from software.amazon.awssdk.services.lambda.model:
The result error is TestError. It carries Type, Message, ErrorData,
and StackTrace. It is distinct from the SDK's durable.ErrorObject.
impl WireError {
// ...
pub fn error_type(&self) -> Option<&str>;
// ...
pub fn error_message(&self) -> Option<&str>;
// ...
pub fn error_data(&self) -> Option<&str>;
// ...
pub fn stack_trace(&self) -> &[String];
// ...
}
The result error is durable::WireError, the type the SDK also returns from
OperationError::wire(). Its fields are private, so a test compares the accessors.
stack_trace() is an empty slice when no frames were recorded. Display writes the
type and the message as type: message, either one alone when the other is absent,
or unknown error when both are absent.
Operation¶
Represents a single entry in the operation history. Each type of operation exposes its own details block (step, wait, callback, chained invoke, context, execution) on top of a shared set of accessors.
Common accessors¶
getName(): string | undefined
getType(): OperationType | undefined
getSubType(): OperationSubType | undefined
getStatus(): OperationStatus | undefined
getStartTimestamp(): Date | undefined
getEndTimestamp(): Date | undefined
getId(): string | undefined
getParentId(): string | undefined
getOperationData(): Operation | undefined
getEvents(): Event[] | undefined
getChildOperations(): DurableOperation[] | undefined
isWaitForCallback(): boolean
isCallback(): boolean
Every operation type inherits these fields from the base Operation dataclass:
TestOperation exposes public fields and an IsTerminal method. Status, Type,
and SubType are strings. A detail pointer is non-nil only when the operation is of
that kind. Child nesting is through ParentID.
type TestOperation struct {
ID string
Name string
Status string
Type string
SubType string
ParentID string
StartTime time.Time
EndTime time.Time
StepDetails *TestStepDetails
CallbackDetails *TestCallbackDetails
InvokeDetails *TestInvokeDetails
ContextDetails *TestContextDetails
WaitDetails *TestWaitDetails
}
func (o *TestOperation) IsTerminal() bool
durable::test_util::TestOperation is a copy of one operation record, read through
accessor methods.
impl TestOperation {
// ...
pub fn id(&self) -> &str;
// ...
pub fn events(&self) -> &[Event];
// ...
pub fn kind(&self) -> OperationKind;
// ...
pub fn sub_type(&self) -> Option<&str>;
// ...
pub fn status(&self) -> OperationStatus;
// ...
pub fn succeeded(&self) -> bool;
// ...
pub fn failed(&self) -> bool;
// ...
pub fn result(&self) -> Option<&str>;
// ...
pub fn result_json<T: DeserializeOwned>(&self) -> Result<Option<T>, serde_json::Error>;
// ...
pub fn is_wait_for_callback(&self) -> bool;
// ...
pub fn wait_duration(&self) -> Option<std::time::Duration>;
// ...
pub fn error(&self) -> Option<&crate::WireError>;
// ...
pub fn error_type(&self) -> Option<&str>;
// ...
pub fn error_message(&self) -> Option<&str>;
// ...
pub fn name(&self) -> Option<&str>;
// ...
pub fn parent_id(&self) -> Option<&str>;
// ...
pub fn start_time(&self) -> Option<std::time::SystemTime>;
// ...
pub fn end_time(&self) -> Option<std::time::SystemTime>;
// ...
pub fn details(&self) -> TestOperationDetails<'_>;
// ...
}
id()The unique operation ID.events()The history events of this operation, in history order.kind()Thedurable::OperationKind(see Operation type).sub_type()The sub-type, such as"Step"or"WaitForCallback", orNone. The constants ofdurable::OperationSubTypehold the built-in values.status()Thedurable::OperationStatus(see Operation status).succeeded()istrueforSucceeded, andfailed()istrueforFailed.result()The serialized result, JSON with the default serdes. UnderCloudRunner, a result over the history's inline size limit isNone.result_json::<T>()The result decoded from JSON intoT. It returnsOk(None)whenresult()isNone, and theserde_json::Errorwhen the result does not decode intoT.is_wait_for_callback()truefor theContextoperation of await_for_callback, whose sub-type isWaitForCallback.wait_duration()The recorded duration of a wait, asWaitDetails::duration()returns it, orNonefor an operation of another kind.error(),error_type(), anderror_message()The recorded error (see TestResultError).name()The operation name, orNonefor an unnamed operation.parent_id()The ID of the operation that contains this one, orNonefor a top-level operation.TestResult::childrenreturns an operation's children.start_time()andend_time()The start and end times.end_time()isNoneuntil the operation ends. UnderLocalRunner, each time is the run's start time plus virtual seconds.details()The details of the operation's kind.
To test for a callback, compare op.kind() == durable::OperationKind::Callback. To
test for a wait_for_callback, call op.is_wait_for_callback().
details() returns the variant of TestOperationDetails that matches kind():
pub enum TestOperationDetails<'a> {
Context(&'a ContextDetails),
Step(&'a StepDetails),
Wait(&'a WaitDetails),
Callback(&'a CallbackDetails),
ChainedInvoke,
}
The enum is #[non_exhaustive], so a match on it needs a _ arm.
The docs "Operation" type maps to TestStep in .NET. Common accessors:
string Id { get; }
string? Name { get; }
string? ParentId { get; }
OperationKind Kind { get; } // Step, Wait, Callback, ChainedInvoke, Context, Execution
string? SubKind { get; } // e.g. "Parallel", "Map", "WaitForCallback"
string Status { get; } // compare against OperationStatus constants
int Attempt { get; } // 1-based for steps, 0 for other kinds
DateTimeOffset? StartedAt { get; }
DateTimeOffset? EndedAt { get; }
TimeSpan? Duration { get; }
IReadOnlyList<TestStep> Children { get; }
Step details¶
op.StepDetails carries the step's recorded state. Attempt counts the
attempts that failed. A step that succeeds on its first attempt records 0. A
step that fails twice and then succeeds records 2. A step that fails on its
only attempt records 1. The cloud runner reads the value from the service's
RetryDetails.CurrentAttempt. Attempt differs from the 1-based
StepContext.Attempt() that the step body reads. Deserialize the step result
with OperationResultAs[T].
impl StepDetails {
pub fn attempt(&self) -> u32;
// ...
pub fn next_attempt_time(&self) -> Option<std::time::SystemTime>;
// ...
}
Match the TestOperationDetails::Step variant of details(), for example
if let TestOperationDetails::Step(step) = op.details(). Read the step's result and
error on the operation (see Common accessors).
attempt()The number of completed retries, counting from 0. A step that succeeds on its first attempt records 0. A step that fails on its only attempt records 0. A step that fails twice and then succeeds records 2. Both runners report the same value. Inside the step body,StepContext::attempt()counts attempts from 1, so the third attempt reads 3 there and the record reads 2.next_attempt_time()The time the next attempt was scheduled for after the latest retry, orNonefor a step that was never retried. A step retry and await_for_conditioncheck that asks to continue both set it. Both runners report the same value.
TestStep exposes step details through kind-aware accessors rather than a separate
details object:
T? GetResult<T>() // deserializes the step result
ErrorObject? GetError() // the step error, or null
int Attempt { get; } // retry attempt (1-based)
GetResult<T>() routes to the right details block based on Kind, so it also works for
chained-invoke, context, and callback operations.
Wait details¶
WaitResultDetails exposes waitSeconds and scheduledEndTimestamp.
op.WaitDetails carries WaitSeconds and ScheduledEndTimestamp.
WaitSeconds comes from the wait's WaitStarted history event and is 0 when
the history has no such event.
impl WaitDetails {
pub fn duration(&self) -> Option<std::time::Duration>;
// ...
pub fn scheduled_end_time(&self) -> Option<std::time::SystemTime>;
// ...
}
Match the TestOperationDetails::Wait variant of details(). duration() is the
recorded wait duration in whole seconds. scheduled_end_time() is the time the wait
was scheduled to end. Under LocalRunner, it is the virtual time the wait started
plus its duration. Each is None when the history recorded no value.
Callback details¶
op.CallbackDetails carries the callback ID, result, and error fields.
Match the TestOperationDetails::Callback variant of details(). callback_id()
is the ID the handler sees. Read the callback's result and error on the operation. A
wait_for_callback records a named Context operation with the sub-type
WaitForCallback. Its children are an unnamed callback operation and the unnamed
step that runs the submitter. Find the callback with
result.children(context).find(|op| op.kind() == durable::OperationKind::Callback).
Chained invoke details¶
op.InvokeDetails carries the invoke result and error fields.
A chained invoke has the TestOperationDetails::ChainedInvoke variant, which
carries no data. Read the invoke's result and error on the operation with result()
and error().
Context details¶
ctx.result: OperationPayload | None
ctx.error: ErrorObject | None
ctx.child_operations: list[Operation]
# Typed lookup among the context's children
ctx.get_step(name) -> StepOperation
ctx.get_wait(name) -> WaitOperation
ctx.get_context(name) -> ContextOperation
ctx.get_callback(name) -> CallbackOperation
ctx.get_invoke(name) -> InvokeOperation
op.ContextDetails carries the context result and error fields. Find the context's
children through ParentID.
Match the TestOperationDetails::Context variant of details().
replay_children() is true when the context closed with the ReplayChildren
option. Only LocalRunner reports it, and CloudRunner reports false. Read the
context's result and error on the operation, and its child operations with
TestResult::children.
Execution details¶
Not applicable.
Not applicable.
The execution outcome is on the result as Status, RawResult, and Error.
Operations excludes the execution operation.
The execution outcome is on the result as status(), output(), and error().
operations() excludes the execution operation.
The .NET SDK does not expose execution details on a step. The top-level execution
operation is excluded from result.Steps, and the execution-level outcome is surfaced
on the result itself via Status, Result, and Error.
Drive a callback from an operation¶
waitForData(status?: WaitingOperationStatus): Promise<DurableOperation>
sendCallbackSuccess(result?: string): Promise<SendDurableExecutionCallbackSuccessResponse>
sendCallbackFailure(error?: ErrorObject): Promise<SendDurableExecutionCallbackFailureResponse>
sendCallbackHeartbeat(): Promise<SendDurableExecutionCallbackHeartbeatResponse>
Driven from the runner. See LocalDurableTestRunner: Drive callbacks.
Driven from the runner. See LocalDurableTestRunner: Drive callbacks.
Drive callbacks from the runner. See Drive callbacks.
Take an OperationHandle from the RunHandle (see Inspect
operations). Await its controls first and the RunHandle
last.
impl OperationHandle {
pub fn snapshot(&self) -> Option<TestOperation>;
// ...
pub async fn wait_for_data(
&self,
status: WaitingStatus,
) -> Result<TestOperation, TestRunnerError>;
// ...
pub async fn send_callback(&self, reply: CallbackReply) -> Result<(), TestRunnerError>;
// ...
pub async fn send_callback_heartbeat(&self) -> Result<(), TestRunnerError>;
// ...
}
snapshot()The operation as it is now, orNonewhile no operation matches.wait_for_data(status)Waits until the operation reachesstatus(see Waiting operation status), and returns the operation at that moment. It returns at once when the operation already reached the status.send_callback(reply)Sends aCallbackReply(see Drive callbacks). The target is the operation when it is a callback, or the callback inside it when it is await_for_callbackcontext. UnderLocalRunner, the reply applies at once.send_callback_heartbeat()Sends a heartbeat to the same target.LocalRunnerruns no heartbeat timer, so a heartbeat changes nothing there.
Errors: wait_for_data returns TestRunnerError::OperationNotReached when the
execution ends before the operation reaches the status. Under CloudRunner, it also
returns OperationNotReached when the max_poll_attempts budget runs out first.
send_callback returns OperationNotFound, NotACallback, or CallbackNotPending
when the handle names no pending callback. send_callback_heartbeat returns those
errors when the handle cannot resolve a callback ID. Under LocalRunner, it also
returns CallbackNotPending after the callback stops waiting and
NoHeartbeatTimeout when the handler set no heartbeat timeout. Under CloudRunner,
it sends the heartbeat without checking the operation status, and a service
rejection returns Aws.
A run fails when awaiting the RunHandle returns Err. A wait_for_data that is
pending at that moment returns the same error. A wait_for_data first awaited after
the run failed returns the stored operation when it already reached the status, and
the run's error otherwise. Under LocalRunner, send_callback and
send_callback_heartbeat return the run's error after the run failed. Under
CloudRunner, they return it only when the run fails before the invoke returns the
execution ARN, and otherwise go on and return their own outcome. A failed history
read or callback call of a CloudRunner control returns Aws from that control
only, and the run goes on.
Driven from the runner, not from the TestStep. Use WaitForCallbackAsync to get the
callback ID and the SendCallback* methods to deliver a result. See
LocalDurableTestRunner: Drive callbacks.
Enums¶
Execution status¶
The terminal status of a durable execution.
ExecutionStatus from @aws-sdk/client-lambda:
| Value | Meaning |
|---|---|
SUCCEEDED |
Execution completed successfully |
FAILED |
Execution failed |
PENDING |
Execution is waiting (callback, invoke, etc.) |
TIMED_OUT |
Execution exceeded its timeout |
STOPPED |
Execution was stopped |
InvocationStatus from aws_durable_execution_sdk_python.execution:
| Value | Meaning |
|---|---|
SUCCEEDED |
Execution completed successfully |
FAILED |
Execution failed |
PENDING |
Execution is waiting |
TIMED_OUT |
Execution exceeded its timeout |
STOPPED |
Execution was stopped |
ExecutionStatus from software.amazon.lambda.durable.model:
| Value | Meaning |
|---|---|
SUCCEEDED |
Execution completed successfully |
FAILED |
Execution failed |
PENDING |
Execution is waiting |
ExecutionStatus is a string type with three values. The cloud runner reports every
terminal status other than SUCCEEDED as Failed.
durable::test_util::ExecutionStatus, which TestResult::status() returns:
| Value | Meaning |
|---|---|
Running |
The execution is still in progress |
Succeeded |
The handler returned a value |
Failed |
The execution failed |
TimedOut |
The execution exceeded its execution timeout. Only CloudRunner reports it |
Stopped |
The execution was stopped. Only CloudRunner reports it |
The enum is #[non_exhaustive], so a match on it needs a _ arm.
InvocationStatus from Amazon.Lambda.DurableExecution:
| Value | Meaning |
|---|---|
Succeeded |
Execution completed successfully |
Failed |
Execution failed |
Pending |
Execution is waiting |
There is no separate execution-status enum. The cloud runner maps the service's
TIMED_OUT and STOPPED terminal states onto Failed.
Operation status¶
The status of an individual operation.
OperationStatus from @aws-sdk/client-lambda:
| Value | Meaning |
|---|---|
STARTED |
Operation is running |
SUCCEEDED |
Operation completed successfully |
FAILED |
Operation failed |
PENDING |
Operation is queued |
CANCELLED |
Operation was cancelled |
TIMED_OUT |
Operation exceeded its timeout |
STOPPED |
Operation was stopped |
OperationStatus from aws_durable_execution_sdk_python.lambda_service. Same values as
TypeScript.
OperationStatus from software.amazon.awssdk.services.lambda.model. Same values as
TypeScript.
TestOperation.Status is a plain string. Compare it against the literal, for
example op.Status == "SUCCEEDED". IsTerminal recognizes the terminal set
SUCCEEDED, FAILED, CANCELLED, TIMED_OUT, and STOPPED. Other observed
values include STARTED, PENDING, and READY.
durable::OperationStatus, which TestOperation::status() returns:
pub enum OperationStatus {
Started,
Pending,
Ready,
Succeeded,
Failed,
Cancelled,
TimedOut,
Stopped,
}
impl OperationStatus {
pub fn wire_str(self) -> &'static str;
// ...
}
| Value | wire_str() |
Meaning |
|---|---|---|
Started |
STARTED |
The operation started and is still in progress |
Pending |
PENDING |
The operation waits for a retry delay to elapse |
Ready |
READY |
The retry delay elapsed and the next attempt can run. Only LocalRunner reports it |
Succeeded |
SUCCEEDED |
The operation completed successfully |
Failed |
FAILED |
The operation failed |
Cancelled |
CANCELLED |
The operation was cancelled |
TimedOut |
TIMED_OUT |
The operation reached its timeout |
Stopped |
STOPPED |
The operation was stopped |
wire_str() returns the service's string for the status.
durable::lambda_types::OperationStatus is the service model type of the same name,
and TestOperation does not use it.
OperationStatus from Amazon.Lambda.DurableExecution.Testing is a static class of
string constants (compare against TestStep.Status), not an enum:
| Constant | Meaning |
|---|---|
OperationStatus.Started |
Operation is running |
OperationStatus.Succeeded |
Operation completed successfully |
OperationStatus.Failed |
Operation failed |
OperationStatus.Pending |
Operation is queued |
OperationStatus.Cancelled |
Operation was cancelled |
OperationStatus.TimedOut |
Operation exceeded its timeout |
OperationStatus.Stopped |
Operation was stopped |
OperationStatus.Ready |
Operation is ready to resume |
Operation type¶
OperationType from @aws-sdk/client-lambda:
| Value | Meaning |
|---|---|
STEP |
A step operation |
WAIT |
A wait operation |
CALLBACK |
A callback operation |
CHAINED_INVOKE |
An invoke operation |
CONTEXT |
A child context |
EXECUTION |
The root execution operation |
OperationType from aws_durable_execution_sdk_python.lambda_service. Same values as
TypeScript.
OperationType from software.amazon.awssdk.services.lambda.model. Same values as
TypeScript.
TestOperation.Type and SubType are strings, and OperationsByType takes a
string. The type strings are STEP, WAIT, CALLBACK, CHAINED_INVOKE,
CONTEXT, and EXECUTION.
durable::OperationKind, which TestOperation::kind() returns:
pub enum OperationKind {
Step,
Wait,
Context,
Callback,
ChainedInvoke,
Execution,
}
impl OperationKind {
pub fn wire_str(self) -> &'static str;
// ...
}
| Value | wire_str() |
Meaning |
|---|---|---|
Step |
STEP |
A step operation |
Wait |
WAIT |
A wait operation |
Context |
CONTEXT |
A child context, such as a map item, a parallel branch, or a wait_for_callback |
Callback |
CALLBACK |
A callback operation |
ChainedInvoke |
CHAINED_INVOKE |
An invoke operation |
Execution |
EXECUTION |
The root execution operation. TestResult::operations() never contains it |
OperationKind from Amazon.Lambda.DurableExecution.Testing (read via TestStep.Kind):
| Value | Meaning |
|---|---|
OperationKind.Step |
A step operation |
OperationKind.Wait |
A wait operation |
OperationKind.Callback |
A callback operation |
OperationKind.ChainedInvoke |
An invoke operation |
OperationKind.Context |
A child context |
OperationKind.Execution |
The root execution operation |
Waiting operation status¶
WaitingOperationStatus from @aws/durable-execution-sdk-js-testing:
| Value | Meaning |
|---|---|
STARTED |
Fires when the operation starts |
SUBMITTED |
For callbacks: fires when the submitter function completes. For other operations: same as COMPLETED |
COMPLETED |
Fires when the operation reaches a terminal status |
Not applicable. Use runner.wait_for_callback() to wait for a callback to become
available.
Not applicable. Use runner.getCallbackId() after run() returns PENDING.
The test drives callbacks through the runner by callback ID. Call OpenCallbacks
after RunUntilComplete returns PENDING.
durable::test_util::WaitingStatus, which OperationHandle::wait_for_data takes:
| Value | An operation reaches it when | A wait_for_callback context reaches it when |
|---|---|---|
Started |
it exists | it has a step or callback child |
Submitted |
it has a terminal status | a step or callback child has a terminal status |
Completed |
it has a terminal status | its callback child and the context have a terminal status |
The terminal statuses are Succeeded, Failed, Cancelled, TimedOut, and
Stopped. The default is Started.
The .NET SDK does not expose a waiting-operation-status enum. After StartAsync, call
WaitForCallbackAsync(arn, name) to obtain the callback ID; the OperationStatus
constants cover the operation lifecycle states.
CloudDurableTestRunner¶
Invokes a deployed Lambda function, polls for completion, and retrieves the full
operation history for assertions. Use it to validate deployment, IAM permissions, and
real service integrations. Both runners share the same TestResult and Operation
types, so tests written against the local runner run unchanged against the cloud runner.
Create a runner¶
new CloudDurableTestRunner({
functionName: string,
client?: LambdaClient,
config?: CloudDurableTestRunnerConfig,
})
Parameters:
functionName(required) The function name or ARN. A qualifier is required for durable functions.client(optional) A configuredLambdaClient. Defaults tonew LambdaClient().config(optional) ACloudDurableTestRunnerConfigobject.
DurableFunctionCloudTestRunner(
function_name: str,
region: str = "us-west-2",
lambda_endpoint: str | None = None,
poll_interval: float = 1.0,
)
Parameters:
function_name(required) The function name or ARN.region(optional) AWS region. Defaults to"us-west-2".lambda_endpoint(optional) Custom Lambda endpoint URL. Defaults toNone.poll_interval(optional) Seconds between status polls. Defaults to1.0.
// Class-based types
CloudDurableTestRunner.create(String functionArn, Class<I> inputType, Class<O> outputType)
// TypeToken-based types
CloudDurableTestRunner.create(String functionArn, TypeToken<I> inputType, TypeToken<O> outputType)
// With custom LambdaClient
CloudDurableTestRunner.create(String functionArn, Class<I> inputType, Class<O> outputType, LambdaClient lambdaClient)
Override settings on an existing runner:
CloudDurableTestRunner<I, O> withLambdaClient(LambdaClient lambdaClient)
CloudDurableTestRunner<I, O> withPollInterval(Duration interval)
CloudDurableTestRunner<I, O> withTimeout(Duration timeout)
CloudDurableTestRunner<I, O> withInvocationType(InvocationType type)
CloudDurableTestRunner<I, O> withSerDes(SerDes serDes)
Parameters:
functionArn(required) The function ARN. A qualifier is required for durable functions.inputType(required) The input type class orTypeToken.outputType(required) The output type class orTypeToken.lambdaClient(optional) A configuredLambdaClient. Defaults to a client usingDefaultCredentialsProvider.
CloudRunner is not generic. You choose the output type when you call ResultAs.
It takes a caller-supplied DurableExecutionAPI client, typically a real
*lambda.Client, so a test can pass a fake. functionName must be a qualified
identifier. Build the client with config.LoadDefaultConfig and
lambda.NewFromConfig.
func NewCloudRunner(api DurableExecutionAPI, functionName string, opts ...CloudRunnerOption) *CloudRunner
Parameters:
api(required) ADurableExecutionAPI, typically a*lambda.Client.functionName(required) The function name, ARN, or partial ARN, with a version or alias qualifier.opts(optional) TheCloudRunnerOptionvaluesWithPollIntervalandWithTimeout.
Returns: *CloudRunner
durable::test_util::CloudRunner invokes a deployed durable function. Each setting
is a builder method that takes the runner and returns it.
impl CloudRunner {
// ...
pub fn new(function_name: impl Into<String>) -> Self;
// ...
pub fn region(mut self, region: impl Into<String>) -> Self;
// ...
pub fn qualifier(mut self, qualifier: impl Into<String>) -> Self;
// ...
pub fn lambda_client(mut self, client: crate::LambdaClient) -> Self;
// ...
}
Parameters:
function_name(required) The function name or ARN.qualifier(optional) The version or alias to invoke. Defaults to$LATEST.region(optional) The AWS Region of the default Lambda client. Defaults to the Region of the ambient AWS configuration, such asAWS_REGION. The runner ignores it when you setlambda_client.lambda_client(optional) A configureddurable::LambdaClient, used for every call. Without it, the runner builds a client from the ambient AWS configuration when the run starts.
Returns: CloudRunner
CloudDurableTestRunner(
string functionArn,
IAmazonLambda? lambdaClient = null,
CloudTestRunnerOptions? options = null)
await using var runner = new CloudDurableTestRunner<OrderInput, OrderResult>(
"arn:aws:lambda:us-east-1:123456789012:function:my-fn:live");
Parameters:
functionArn(required) The qualified function ARN (with alias, version, or$LATEST). A qualifier is required for durable functions.lambdaClient(optional) AnIAmazonLambdaclient. When null, the runner creates and owns anAmazonLambdaClientusing the default credential chain and disposes it onDisposeAsync.options(optional) ACloudTestRunnerOptionsrecord for poll intervals, timeout, and serializer.
The input and output types are the runner's type parameters, so no separate inputType
or outputType argument is needed.
Run the handler¶
Parameters:
params(optional) AnInvokeRequestobject.payload(optional) The input payload.
Returns: Promise<TestResult>
Parameters:
input(optional) JSON-encoded input string.timeout(optional) Maximum seconds to wait. Defaults to60.
Returns: DurableFunctionTestResult
Raises: TimeoutError if the execution does not complete within timeout.
Parameters:
input(required) The handler input.
Returns: TestResult<O>
Run invokes the function and polls to a terminal status. It takes a
context.Context and an any event. RunWithArn polls an execution started
elsewhere. The timeout and poll interval are set at construction.
func (r *CloudRunner) Run(ctx context.Context, event any) (*TestResult, error)
func (r *CloudRunner) RunWithArn(ctx context.Context, executionArn string) (*TestResult, error)
Parameters:
ctx(required) Acontext.Context. Its deadline or cancellation ends the run.event(required) The handler input. The runner encodes it as JSON.executionArn(required) The ARN of an execution that is already running.
Returns: *TestResult and an error.
Errors: The error is non-nil, and the result nil, only when the runner itself
fails. That happens when the event does not encode as JSON, the Invoke call fails,
the invoke response has no DurableExecutionArn, polling fails or exceeds the
WithTimeout limit, or ctx ends. An execution that fails produces a nil error and
a result with status FAILED.
pub fn run<O>(&self, event: impl Serialize) -> RunHandle<O>
where
O: DeserializeOwned + Send + 'static;
run returns a RunHandle<O> at once. Awaiting it invokes the function, polls
GetDurableExecution until the execution ends or the configured poll budget is
spent, and reads the whole history into the result. A spent budget produces a result
with status ExecutionStatus::Running. Configure polling and
timeouts gives the budget and the attempts of each
Lambda call. The runner ignores the invoke response's payload. No handler gives the
output type, so name it at the call, for example runner.run::<String>(event).
Parameters:
event(required) The handler input. The runner encodes it as JSON and sends it as the invoke payload.
Returns: RunHandle<O>. Awaiting it returns
Result<TestResult<O>, TestRunnerError>.
Errors: Awaiting the handle returns Err(TestRunnerError) only when the runner
itself fails. That happens when the event does not encode as JSON
(EventSerialization), or one of the run's own Lambda calls fails (Aws). Those
calls are Invoke, the third attempt of a GetDurableExecution poll, and the
history read that builds the result. It also happens when the invoke response
carries no DurableExecutionArn (NoExecutionArn), or the output of a succeeded
execution does not decode into O (OutputDeserialization). A failed call of an
operation control fails only that control (see Drive a callback from an
operation). An execution that fails, times
out, or stops produces Ok with a result of that status.
Task<TestResult<TOutput>> RunAsync(
TInput input,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
Invokes the deployed function (Event invocation), polls the durable-execution history
until the execution reaches a terminal state, and returns the folded result. This is the
same signature as the local runner, so tests written against
IDurableTestRunner<TInput, TOutput> run unchanged on either backend.
Parameters:
input(required) The handler input.timeout(optional) Wall-clock timeout. Defaults toCloudTestRunnerOptions.DefaultTimeout(5 minutes).cancellationToken(optional) ACancellationToken.
Returns: Task<TestResult<TOutput>>
Run asynchronously¶
Not applicable. run() already drives the full replay loop.
The returned AsyncExecution<O> exposes pollUntil(), pollUntilComplete(),
isComplete(), hasOperation(), hasCallback(), getCallbackId(), getOperation(),
getOperations(), getStatus(), getExecutionArn(), completeCallback(),
failCallback(), and heartbeatCallback().
To poll an execution started elsewhere, for example an async invoke, use
RunWithArn.
run returns the RunHandle at once. To act on the execution before it ends, set
InvocationType::Event (see
CloudDurableTestRunnerConfig) and use the operation
handles, as on LocalRunner (see Drive a callback from an
operation). Await the operation controls first
and the RunHandle last.
The .NET SDK does not return a dedicated async-execution handle. Use the same
StartAsync + WaitForResultAsync pair as the local runner:
Task<string> StartAsync(
TInput input, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
Task<TestResult<TOutput>> WaitForResultAsync(
string durableExecutionArn, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
StartAsync fires an Event invocation and resolves the durable execution ARN by
listing executions. Drive callbacks with WaitForCallbackAsync and the SendCallback*
methods between the two calls.
Inspect operations¶
Inspect operations through the test result. See TestResult.
Inspect operations through the result, the same accessors as the local runner. See TestResult.
Take operation handles from the RunHandle with operation, operation_by_index,
operation_by_name_and_index, and operation_by_id. See Inspect
operations for their selectors and return type. Under
CloudRunner, snapshot() returns the operation in the last history the runner read.
After the run, use the result's lookups. See Operations.
Inspect operations through the returned TestResult<TOutput>. The cloud
result exposes the same Steps list and GetStep/FindStep/GetSteps accessors as the
local runner. See TestResult.
Drive callbacks¶
Callback interaction is on the DurableOperation object. See
Drive a callback from an operation.
Drive callbacks through the AsyncExecution<O> returned by startAsync(). Use
getCallbackId(), completeCallback(), failCallback(), and heartbeatCallback() on
the async handle.
Drive callbacks from the runner, with a callback ID from the execution's operations
or history. These methods make the real SendDurableExecutionCallback* API calls.
Drive callbacks from an operation handle, as on LocalRunner. See Drive a callback
from an operation. The controls need
InvocationType::Event. Under the default RequestResponse, wait_for_data,
send_callback, and send_callback_heartbeat return
TestRunnerError::Unsupported, whose message names the control, and make no Lambda
call.
send_callback reads the history again, checks that the callback is still
Started, and calls SendDurableExecutionCallbackSuccess or
SendDurableExecutionCallbackFailure. send_callback_heartbeat reads the history
again to find the callback ID and calls SendDurableExecutionCallbackHeartbeat.
When the poll budget is spent, both controls use the last history the runner read
instead. send_callback returns TestRunnerError::Unsupported when it gets a
CallbackReply::timeout() or a CallbackReply::heartbeat_timeout() reply.
Callbacks are driven from the runner, same as the local runner. After StartAsync, call
WaitForCallbackAsync and then SendCallbackSuccessAsync, SendCallbackFailureAsync,
or SendCallbackHeartbeatAsync. On the cloud runner these issue the corresponding
SendDurableExecutionCallback* Lambda API calls.
Configure polling and timeouts¶
Pass config: { pollInterval, invocationType } to the constructor. See
CloudDurableTestRunnerConfig.
Pass poll_interval to the constructor. Pass timeout to run() or run_async() to
set the maximum wait.
Use withPollInterval(Duration), withTimeout(Duration), and
withInvocationType(InvocationType) on the runner.
Configure with the constructor options WithPollInterval (default 2 seconds) and
WithTimeout (default 5 minutes).
pub fn poll_interval(mut self, interval: std::time::Duration) -> Self;
// ...
pub fn max_poll_attempts(mut self, max: usize) -> Self;
// ...
pub fn invocation_type(mut self, invocation_type: InvocationType) -> Self;
poll_interval(interval)The wait before eachGetDurableExecutionpoll, before each history read ofwait_for_data, and before each history page that follows a page with a next marker. Defaults to 1 second.max_poll_attempts(max)The shared poll budget forGetDurableExecutionpolls and operation-control history reads. A poll counts once however many attempts it makes. A control's history read counts once however many pages and attempts it makes. The history read that builds the final result does not count. When the runner spends the budget while the execution still runs, the result has statusExecutionStatus::Running. A value of 0 counts as 1. By default the budget is unlimited, so the runner polls until the execution ends or aGetDurableExecutionpoll fails on its third attempt.invocation_type(invocation_type)How the runner invokes the function. Defaults toInvocationType::RequestResponse. See CloudDurableTestRunnerConfig.
Each GetDurableExecution call and each history page request makes up to three
attempts on any error. The second attempt starts 1000 ms after the first fails, and
the third starts 1450 ms after the second fails. The error of the third attempt is
the error of the call. Invoke and the callback calls make one attempt.
max_poll_attempts limits polls by count. To impose a wall-clock deadline, await the
RunHandle under tokio::time::timeout.
Configure polling and timeout through the CloudTestRunnerOptions record passed to the
constructor. Per-call timeouts can also be passed to RunAsync/WaitForResultAsync:
await using var runner = new CloudDurableTestRunner<OrderInput, OrderResult>(
functionArn,
options: new CloudTestRunnerOptions
{
InitialPollInterval = TimeSpan.FromMilliseconds(200),
PollInterval = TimeSpan.FromSeconds(2),
DefaultTimeout = TimeSpan.FromMinutes(5),
});
The invocation type is fixed to Event (fire-and-forget) so callback workflows do not
deadlock; there is no invocationType knob.
Reset between runs¶
Create a new runner instance per test.
Create a new runner instance per test.
Create a new CloudRunner per test.
A runner holds only settings, and run borrows it. Each run starts a new execution,
so one runner serves several cases with no reset.
There is no reset(). Create a new CloudDurableTestRunner<TInput, TOutput> per test
and dispose it with await using (which disposes a runner-owned Lambda client).
Reference types¶
CloudDurableTestRunnerConfig¶
Fields:
pollInterval(optional) Milliseconds between history polls. Defaults to1000.invocationType(optional) Lambda invocation type. Defaults toInvocationType.RequestResponse.
Not a separate config object. Pass poll_interval directly to the constructor.
Not a separate config object. Use the with* builder methods on the runner.
Configure the runner with the NewCloudRunner options WithPollInterval and
WithTimeout, shown under Configure polling and
timeouts. The cloud runner calls Invoke without
an invocation type, so Lambda uses its default, RequestResponse.
Configure the runner with the builder methods under Create a
runner and Configure polling and
timeouts. invocation_type takes a
durable::test_util::InvocationType:
RequestResponse(default) A synchronous invoke. It returns after the execution ended or the invocation reached its timeout.EventAn asynchronous invoke. It returns once the execution started. The operation controls need it.
The .NET equivalent is the CloudTestRunnerOptions record:
public sealed record CloudTestRunnerOptions
{
public TimeSpan InitialPollInterval { get; init; } = TimeSpan.FromMilliseconds(200);
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromMinutes(5);
public ILambdaSerializer? Serializer { get; init; }
}
Fields:
InitialPollInterval(optional) Delay before the first poll retry; subsequent delays grow exponentially up toPollInterval. Defaults to 200 milliseconds.PollInterval(optional) Maximum steady-state interval between polls. Defaults to 2 seconds.DefaultTimeout(optional) Wall-clock timeout for polling. Defaults to 5 minutes.Serializer(optional) AnILambdaSerializerfor payload and result serialization. Defaults toDefaultLambdaJsonSerializer.
See also¶
- Authoring Set up the local runner and write your first test.
- Assertions Inspect steps, waits, and callbacks after a test run.
- Workflow patterns Complete tests for common workflow shapes.
- Cloud Runner Run tests against a deployed Lambda function.
- Runner How the replay loop and checkpointing work.