DEV Community

Cover image for Test Step Functions with the TestState API
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Test Step Functions with the TestState API

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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+\}/);
});
Enter fullscreen mode Exit fullscreen mode

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,
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

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: { reservationId: "res-1001" } },
  });
  assert.equal(res.status, "SUCCEEDED");
  assert.equal(res.nextState, "UnlockBike");
  assert.deepEqual(JSON.parse(res.output ?? "{}").reservation, { reservationId: "res-1001" });
});

test("throttling is retried with backoff", { skip }, async () => {
  const res = await runState("ReserveBike", {
    input,
    mockError: { error: "Lambda.TooManyRequestsException", cause: "Rate exceeded" },
    retrierRetryCount: 1,
  });
  assert.equal(res.status, "RETRIABLE");
  assert.equal(res.inspectionData?.errorDetails?.retryIndex, 0);
  // IntervalSeconds 2 x BackoffRate 2 ^ 1 retry so far = 4 seconds
  assert.equal(res.inspectionData?.errorDetails?.retryBackoffIntervalSeconds, 4);
});

test("after the last retry the catch-all takes over", { skip }, async () => {
  const res = await runState("ReserveBike", {
    input,
    mockError: { error: "Lambda.TooManyRequestsException", cause: "Rate exceeded" },
    retrierRetryCount: 3,
  });
  assert.equal(res.status, "CAUGHT_ERROR");
  assert.equal(res.nextState, "RentalFailed");
  assert.equal(res.inspectionData?.errorDetails?.catchIndex, 1);
});

test("no bike available leads to another station, without retries", { skip }, async () => {
  const res = await runState("ReserveBike", {
    input,
    mockError: { error: "BikeUnavailable", cause: "Station harbour-3 is empty" },
  });
  assert.equal(res.status, "CAUGHT_ERROR");
  assert.equal(res.nextState, "OfferOtherStation");
  const output = JSON.parse(res.output ?? "{}");
  assert.equal(output.error.Error, "BikeUnavailable");
  assert.equal(output.rentalId, "r-42");
});

test("unverified riders are rejected", { skip: hasAwsCredentials && process.env.SFN_TEST_ROLE_ARN ? false : "needs SFN_TEST_ROLE_ARN" }, async () => {
  const res = await runState("CheckRider", { input: { ...input, rider: { id: "u-8", verified: false } } });
  assert.equal(res.status, "SUCCEEDED");
  assert.equal(res.nextState, "RejectRental");
});
Enter fullscreen mode Exit fullscreen mode

The Choice check in the last test has no mock, because mocks only exist for Task, Map and Parallel states. The examples in the AWS documentation pass a role for it, so this test only runs when you set SFN_TEST_ROLE_ARN. Since a Choice state calls no service, a role without further service permissions should be enough.

Step 5: Run the tests

Add the scripts to package.json and start the tests. The region matters, otherwise the SDK stops with “Region is missing”.

npm pkg set scripts.typecheck="tsc --noEmit" scripts.test="node --test test/*.test.ts"
export AWS_REGION=eu-central-1
npm run typecheck
npm test
Enter fullscreen mode Exit fullscreen mode

With valid credentials, all tests run against the TestState API. Without credentials, the guardrail tests still run and the cloud tests are skipped cleanly. The expected result then looks like this:

ok 1 - every Lambda task retries throttling and has a catch-all
ok 2 - States.ALL is always the last catcher
ok 3 - every transition points to an existing state
ok 4 - all placeholders are replaced
ok 5 - happy path: reservation lands in $.reservation # SKIP no AWS credentials – TestState runs in the cloud
...
# pass 4
# fail 0
# skipped 5
Enter fullscreen mode Exit fullscreen mode

Step 6: Deploy the same definition with CDK

For deployment, the CDK stack reads exactly the file the tests use. DefinitionBody.fromFile uploads it as an asset, and definitionSubstitutions inserts the real function ARNs. The Lambda functions are deliberately simple. What matters is that BikeUnavailable arrives as the error name, which is why the error class sets this.name.

// src/reserve-bike.ts
class BikeUnavailable extends Error {
  constructor(stationId: string) {
    super(`No bike available at station ${stationId}`);
    this.name = "BikeUnavailable"; // becomes the error name in Step Functions
  }
}

interface RentalRequest {
  rentalId: string;
  stationId: string;
}

// Demo stock instead of a real database
const stock: Record<string, number> = { "harbour-3": 2, "old-town-1": 0 };

export const handler = async (event: RentalRequest) => {
  const free = stock[event.stationId] ?? 0;
  if (free < 1) throw new BikeUnavailable(event.stationId);
  return { reservationId: `res-${event.rentalId}` };
};
Enter fullscreen mode Exit fullscreen mode
// src/unlock-bike.ts
export const handler = async (event: { rentalId: string; reservation: { reservationId: string } }) => {
  console.log(JSON.stringify({ msg: "unlock", rentalId: event.rentalId, reservationId: event.reservation.reservationId }));
  return { unlocked: true };
};
Enter fullscreen mode Exit fullscreen mode
// lib/bike-rental-stack.ts
import * as path from "node:path";
import { Duration, Stack, type StackProps } from "aws-cdk-lib";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { DefinitionBody, StateMachine, StateMachineType } from "aws-cdk-lib/aws-stepfunctions";
import type { Construct } from "constructs";

export class BikeRentalStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const common = { runtime: Runtime.NODEJS_22_X, memorySize: 256, timeout: Duration.seconds(10) };
    const reserveBike = new NodejsFunction(this, "ReserveBike", { ...common, entry: "src/reserve-bike.ts" });
    const unlockBike = new NodejsFunction(this, "UnlockBike", { ...common, entry: "src/unlock-bike.ts" });

    const rental = new StateMachine(this, "BikeRental", {
      stateMachineType: StateMachineType.STANDARD,
      // The same ASL file that the tests read
      definitionBody: DefinitionBody.fromFile(path.join("statemachine", "bike-rental.asl.json")),
      definitionSubstitutions: {
        ReserveBikeFunctionArn: reserveBike.functionArn,
        UnlockBikeFunctionArn: unlockBike.functionArn,
      },
    });

    reserveBike.grantInvoke(rental);
    unlockBike.grantInvoke(rental);
  }
}
Enter fullscreen mode Exit fullscreen mode
// bin/app.ts
import { App } from "aws-cdk-lib";
import { BikeRentalStack } from "../lib/bike-rental-stack.ts";

const app = new App();
new BikeRentalStack(app, "BikeRentalStack");
Enter fullscreen mode Exit fullscreen mode
echo '{ "app": "node bin/app.ts" }' > cdk.json
npx cdk synth
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

In the synthesized template, you will find the AWS::StepFunctions::StateMachine resource with DefinitionS3Location and both substitutions. If you prefer the Serverless Framework, the serverless-step-functions plugin gets you to the same place. The tests stay unchanged, because they only know the ASL file.

Typical mistakes when testing with TestState

A few pitfalls stood out while I worked through the documentation and built the example:

  • Missing region: The SDK needs a region. Set AWS_REGION or add it to your profile.
  • No mock, no role: Without a mock, the TestState API has to call real services and needs a role with matching permissions. The helper above deliberately stops with a clear message in that case.
  • Mock does not match the API model: In STRICT mode, Step Functions rejects mocks with wrong field names or types. For .sync integrations, the mock must also match the response of the polling API, so for startExecution.sync:2 it must look like DescribeExecution.
  • Map and Parallel: A mock is required here. The test covers input and output processing of the outer state, not the individual iterations. You test the inner state separately via stateName.
  • Console instead of code: According to the documentation, the console does not support mocks or Map and Parallel tests yet. For everything in this article, you need the CLI or an SDK.
  • Exhausted retries: retrierRetryCount counts the retries that have already happened. If the value equals MaxAttempts, no attempts are left and the catcher takes over. If your test shows something else, look at the inspection data with DEBUG.

Cost and security when testing

The TestState API creates no state machine and starts no execution. The Step Functions pricing page does not list TestState as a separate item. What it bills are state transitions of real executions: for Standard workflows in US East (N. Virginia), 0.000025 US dollars per transition, with 4,000 free transitions per month. Still, check the current pricing page for your region before using it in large CI pipelines.

For security, the rule is simple: mocked tests only need states:TestState. Give your CI role exactly this permission and no iam:PassRole as long as you only work with mocks. Only tests with real calls need an execution role, which you should scope tightly as well. Leave out the revealSecrets parameter in CI pipelines, so no credentials from HTTP tasks end up in test logs.

My take

For me, the TestState API with mocks is the most useful testing tool Step Functions has received so far. It tests the real execution logic instead of a replica, and it makes exactly those parts testable that otherwise only run in an emergency. Especially with patterns like the saga pattern with Step Functions, correctness depends on catch rules and compensations. Those should not only be right on a whiteboard.

Still, TestState does not replace a test after deployment. The API checks one state after another, mocked and without real IAM permissions. Whether the role of the state machine is really allowed to invoke the Lambda function is something you will not see this way. So my split would be: guardrail tests offline on every commit, TestState tests with mocks in CI and, after deployment, a single real execution as a smoke test.

One point where I disagree with some teams: they try to test every combination of errors and states. That quickly turns into a test suite nobody understands anymore. Instead, test the decisions that matter for the business. For the bike rental, these are “throttling is retried”, “no bike available goes to another station” and “everything else fails in a controlled way”. By the way, I wrote about how long the pauses between retries should be in more detail in exponential backoff with jitter.

FAQ

Can I test Step Functions locally?

Step Functions Local still exists, but AWS calls it unsupported and without feature parity. For tests before deployment, AWS recommends the TestState API. You can additionally check the structure of the ASL file offline with your own tests.

Does the TestState API need an IAM role?

Only without a mock. As soon as you mock a result or an error, roleArn is optional. The caller always needs the states:TestState permission.

Can TestState run a whole state machine?

No, each call tests exactly one state. However, you can chain tests by using the output and nextState of one call as the input for the next.

Does TestState work with Map and Parallel states?

Yes, with a mock. The test covers input and output processing of the outer state. For Parallel, the mock must be an array with one element per branch, and for errors you name the inner state that caused them with errorCausedByState.

Your next step

Pick the state machine in your setup with the most error handling. Write exactly one test with a mocked error for each catcher and run it in CI. If you are still deciding whether Step Functions is the right service for your workflow at all, the AWS service finder helps you sort it out.

And now I am curious: how do you test your Step Functions workflows today, and which error path surprised you most recently in production?

Sources

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)