For a long time, I treated the question of how to test Step Functions as something for “later”. You check the happy path anyway, because you see it while trying things out. The Retry and Catch blocks, however, are a piece of JSON that only really runs for the first time when something goes wrong in production. That is exactly the wrong place to find out that an error name has a typo.

With the TestState API, there is now a tool I would use in every Step Functions project. It lets you test a single state of your state machine in the cloud without deploying it. Since mocks are supported, you do not even need real Lambda functions or an IAM role. In this article, we build the whole thing with a small example: a bike rental with a retry for throttling, a catch for “no bike available” and tests with node:test in TypeScript.

In short

The TestState API runs exactly one state of your Step Functions definition and reports whether it succeeded, would be retried or ends up in a catch. With mocked results and errors, you can test retry and catch paths in a targeted way, without a deployment and without an IAM role. The biggest pitfall: the API only ever tests one state, so for a run through the whole machine with real permissions you also need a short test after deployment.

What you will learn in this article

  • What the TestState API can do and where its limits are
  • How to check retry, backoff and catch with mocked errors
  • How tests and the CDK stack share the same ASL file
  • Which guardrail tests run without any AWS access
  • Which typical mistakes happen when testing

Why teams rarely test Step Functions properly

AWS Step Functions is the orchestration service of AWS. You describe a workflow as a state machine in the Amazon States Language (ASL), a JSON format, and Step Functions runs the steps, waits, branches and retries. This is practical, because error handling lives declaratively in the definition instead of being scattered across code. However, that is also where the testing problem starts.

A Retry block is configuration. For example, it says: on Lambda.TooManyRequestsException, try three times, start with a two second pause and double the wait each time. Whether the error names are right, whether the order of the catchers fits and whether the error ends up in the right next state is something you will not notice during normal testing. After all, the error case simply does not happen.

For years, the answer was Step Functions Local, an emulator in a Docker container. AWS now states very clearly in the documentation that Step Functions Local does not provide feature parity and is unsupported. Instead, AWS explicitly recommends the TestState API. That matches my view: an emulator that lags behind the real service creates false confidence. A test against the real execution logic in the cloud is more honest.

What the TestState API does

The TestState API takes an ASL definition and runs exactly one state. You either pass only that single state or the complete state machine plus the name of the state in the stateName field. No state machine is created or changed. In response, you get the status, the output, the next state and, if you ask for it, details about data processing.

The four statuses at a glance

The status is the heart of your tests. It tells you what Step Functions would do with the result of the state:

Status Meaning Typical test
SUCCEEDED The state completed, nextState names the following state. Happy path, shaping data with ResultSelector and ResultPath
RETRIABLE The error matches a retrier and attempts are left. Throttling, short service errors
CAUGHT_ERROR A catcher handled the error. Business errors, exhausted retries
FAILED Neither retry nor catch apply, the execution would fail. Spotting missing error handling

With inspectionLevel, you control the level of detail. INFO is the default and only returns the result. With DEBUG, you also see the intermediate values after InputPath, Parameters, ResultSelector and ResultPath, and in case of an error the fields retryIndex, retryBackoffIntervalSeconds and catchIndex. TRACE adds the full HTTP request and response for HTTP tasks.

Mocks instead of real Lambda calls

The most important building block is the mock field. For Task, Map or Parallel states, it lets you provide either a result (mock.result) or an error (mock.errorOutput with error and cause). Both at once are not allowed. As soon as a mock is set, roleArn becomes optional according to the documentation. You then test pure workflow logic, and no service is called at all.

In addition, Step Functions validates the mock against the API model of the service. With fieldValidationMode, you choose how strict this is: STRICT (default) checks names, types and required fields, PRESENT only checks the fields in the mock, and NONE turns validation off. I would stay with STRICT, because a mock that can never exist in reality tests nothing.

For retry tests, there is also stateConfiguration.retrierRetryCount. It simulates how many retries have already happened for the matching retrier. For Map and Parallel states, errorCausedByState names the inner state the error came from.

Three test layers for Step Functions: offline guardrail tests, TestState with mocks in CI, smoke test after deployment

Fast and cheap on the left, realistic on the right: each layer catches different mistakes.

The example: a bike rental with retry and catch

As an example, we take renting a bike, a deliberately small, fictional domain. The state machine first checks whether the rider is verified. After that, a Lambda function reserves a bike at the station, and a second function unlocks it. Two kinds of errors are interesting:

  • Throttling by Lambda (Lambda.TooManyRequestsException) is temporary and should be retried with backoff.
  • The business error BikeUnavailable (“no bike available”) should not be retried, but lead directly to a hint about another station.

Everything else ends up in the RentalFailed state via States.ALL. The placeholders ${ReserveBikeFunctionArn} and ${UnlockBikeFunctionArn} are replaced by CloudFormation through DefinitionSubstitutions on deployment, while the tests replace them with dummy ARNs. This way, tests and infrastructure share the same file.

{ "Comment": "Bike rental: check rider, reserve bike, unlock bike", "StartAt": "CheckRider", "States": { "CheckRider": { "Type": "Choice", "Choices": [ { "Variable": "$.rider.verified", "BooleanEquals": true, "Next": "ReserveBike" } ], "Default": "RejectRental" }, "ReserveBike": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "FunctionName": "${ReserveBikeFunctionArn}", "Payload.$": "$" }, "ResultSelector": { "reservationId.$": "$.Payload.reservationId" }, "ResultPath": "$.reservation", "Retry": [ { "ErrorEquals": ["Lambda.TooManyRequestsException", "Lambda.ServiceException"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": ["BikeUnavailable"], "ResultPath": "$.error", "Next": "OfferOtherStation" }, { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RentalFailed" } ], "Next": "UnlockBike" }, "UnlockBike": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "FunctionName": "${UnlockBikeFunctionArn}", "Payload.$": "$" }, "ResultPath": null, "Retry": [ { "ErrorEquals": ["Lambda.TooManyRequestsException", "Lambda.ServiceException"], "IntervalSeconds": 1, "MaxAttempts": 2, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RentalFailed" } ], "End": true }, "OfferOtherStation": { "Type": "Pass", "Parameters": { "status": "NO_BIKE", "stationId.$": "$.stationId", "hint": "Try a nearby station" }, "End": true }, "RejectRental": { "Type": "Fail", "Error": "RiderNotVerified", "Cause": "The rider has not been verified yet" }, "RentalFailed": { "Type": "Fail", "Error": "RentalFailed", "Cause": "Reservation or unlock failed" } } } 

One detail that is often overlooked: States.ALL must always be the last catcher. Step Functions checks the catchers in order, so an early catch-all would swallow the specific BikeUnavailable case. We will check exactly that automatically in a moment.

🎬 How a TestState test runs – this animated diagram only runs on my site: watch it on sascha-ms.de

How to test Step Functions step by step with TypeScript

Now let us build the tests. I use the built-in test runner of Node.js (node:test) and the AWS SDK for JavaScript v3. Node.js 22.18 and later runs TypeScript files directly as long as they only contain type annotations. So the tests need no extra build step.

Prerequisites

You need Node.js 22.18 or later, an AWS account with credentials on the command line (for example via aws sso login) and the AWS CDK for deployment. The identity you test with needs the states:TestState permission. For tests without a mock, you also need iam:PassRole for the execution role.

Step 1: Create the project

mkdir bike-rental && cd bike-rental npm init -y npm pkg set type=module npm install @aws-sdk/client-sfn aws-cdk-lib constructs npm install -D typescript @types/node aws-cdk esbuild mkdir statemachine src test lib bin 

Then save the ASL file from above as statemachine/bike-rental.asl.json. The tsconfig.json is only used for type checking, which is why noEmit is set to true:

{ "compilerOptions": { "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "types": ["node"], "skipLibCheck": true }, "include": ["bin", "lib", "src", "test"] } 

Step 2: Guardrail tests without AWS access

Before we ask the cloud, a quick look at the structure pays off. These tests run completely offline and finish in milliseconds. They check rules I want to see in every review: every Lambda task retries throttling, has a catch-all, and that catch-all comes last. In addition, all transitions point to existing states.

// test/asl-guardrails.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { loadDefinition } from "./test-state.ts"; interface AslState { Type: string; Resource?: string; Retry?: { ErrorEquals: string[]; MaxAttempts?: number }[]; Catch?: { ErrorEquals: string[]; Next: string }[]; Next?: string; Default?: string; Choices?: { Next: string }[]; } const definition = JSON.parse(loadDefinition()) as { StartAt: string; States: Record<string, AslState> }; const states = Object.entries(definition.States); test("every Lambda task retries throttling and has a catch-all", () => { for (const [name, state] of states.filter(([, s]) => s.Type === "Task")) { const retried = (state.Retry ?? []).flatMap((r) => r.ErrorEquals); assert.ok(retried.includes("Lambda.TooManyRequestsException"), `${name}: Retry for throttling is missing`); const caught = (state.Catch ?? []).flatMap((c) => c.ErrorEquals); assert.ok(caught.includes("States.ALL"), `${name}: Catch with States.ALL is missing`); } }); test("States.ALL is always the last catcher", () => { for (const [name, state] of states) { const catchers = state.Catch ?? []; const index = catchers.findIndex((c) => c.ErrorEquals.includes("States.ALL")); if (index >= 0) assert.equal(index, catchers.length - 1, `${name}: States.ALL must come last`); } }); test("every transition points to an existing state", () => { const known = new Set(Object.keys(definition.States)); assert.ok(known.has(definition.StartAt)); for (const [name, state] of states) { const targets = [state.Next, state.Default, ...(state.Choices ?? []).map((c) => c.Next), ...(state.Catch ?? []).map((c) => c.Next)]; for (const target of targets.filter(Boolean)) { assert.ok(known.has(target as string), `${name}: unknown target ${target}`); } } }); test("all placeholders are replaced", () => { assert.doesNotMatch(loadDefinition(), /\$\{\w+\}/); }); 

Step 3: A small helper for the TestState API

The helper loads the definition, replaces the placeholders and calls TestStateCommand. Without a mock, it requires a role from the SFN_TEST_ROLE_ARN environment variable, so nobody calls real Lambda functions by accident.

// test/test-state.ts import { readFileSync } from "node:fs"; import { SFNClient, TestStateCommand, type TestStateCommandOutput } from "@aws-sdk/client-sfn"; const client = new SFNClient({}); // Replace the CDK placeholders with dummy ARNs, just like DefinitionSubstitutions does on deploy. const placeholders: Record<string, string> = { ReserveBikeFunctionArn: "arn:aws:lambda:eu-central-1:123456789012:function:reserve-bike", UnlockBikeFunctionArn: "arn:aws:lambda:eu-central-1:123456789012:function:unlock-bike", }; export function loadDefinition(file = "statemachine/bike-rental.asl.json"): string { const raw = readFileSync(file, "utf8"); return raw.replace(/\$\{(\w+)\}/g, (match, key: string) => placeholders[key] ?? match); } export const hasAwsCredentials = Boolean( process.env.AWS_PROFILE || process.env.AWS_ACCESS_KEY_ID || process.env.AWS_WEB_IDENTITY_TOKEN_FILE, ); export interface RunOptions { input: unknown; mockResult?: unknown; mockError?: { error: string; cause: string }; retrierRetryCount?: number; } // Runs exactly one state of the full definition via the TestState API. export async function runState(stateName: string, opts: RunOptions): Promise<TestStateCommandOutput> { const mocked = opts.mockResult !== undefined || opts.mockError !== undefined; const roleArn = process.env.SFN_TEST_ROLE_ARN; if (!mocked && !roleArn) { throw new Error(`State ${stateName} is tested without a mock: please set SFN_TEST_ROLE_ARN`); } return client.send( new TestStateCommand({ definition: loadDefinition(), stateName, input: JSON.stringify(opts.input), inspectionLevel: "DEBUG", roleArn: mocked ? undefined : roleArn, mock: mocked ? { result: opts.mockResult !== undefined ? JSON.stringify(opts.mockResult) : undefined, errorOutput: opts.mockError, } : undefined, stateConfiguration: opts.retrierRetryCount !== undefined ? { retrierRetryCount: opts.retrierRetryCount } : undefined, }), ); } 

Step 4: Test retry and catch

Now for the actual tests. Each test simulates a result or an error of the Lambda function and checks what Step Functions does with it. The second test is my favourite: it recalculates the backoff. With IntervalSeconds 2, BackoffRate 2 and one retry already done, the next pause is 2 × 2¹ = 4 seconds. This calculation matches the example in the AWS documentation.

// test/reserve-bike.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { hasAwsCredentials, runState } from "./test-state.ts"; const input = { rentalId: "r-42", stationId: "harbour-3", rider: { id: "u-7", verified: true } }; const skip = hasAwsCredentials ? false : "no AWS credentials – TestState runs in the cloud"; test("happy path: reservation lands in $.reservation", { skip }, async () => { const res = await runState("ReserveBike", { input, mockResult: { StatusCode: 200, Payload: { Source:https://dev.to/sascha-ms/test-step-functions-with-the-teststate-api-1ge9

Related posts