Skip to content

AWS SDK interception

SimSdk routes AWS SDK for JavaScript v3 commands to Yulin. Use it to test code that already sends commands through AWS SDK clients.

Application tests should normally create one SimSdk in Vitest suite setup and keep its class interceptions installed for the whole suite. Every SDK client then reaches the same simulated state, including clients created in different test files.

The test suite setup guide shows how to share one in-process environment across Vitest files. It also covers the worker and isolation settings this requires. A separate SimSdk for each test or file remains supported when a case needs an empty environment. Tests that control the simulation’s clock should use a separate SimSdk so their time changes cannot affect the shared suite.

Create a SimSdk, intercept a client class, and run the code under test:

/**
* Intercepting the S3 SDK client with simulated AWS behind it.
*/
import {
CreateBucketCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { SimSdk } from "@kensio/yulin/sdk";
const simSdk = new SimSdk();
simSdk.intercept(S3Client); // Intercepts every instance of the class.
// From here on, this is ordinary AWS SDK code.
const s3Client = new S3Client({ region: "eu-west-2" });
await s3Client.send(new CreateBucketCommand({ Bucket: "foo-bucket" }));
await s3Client.send(
new PutObjectCommand({
Bucket: "foo-bucket",
Key: "hello.txt",
Body: "Hello, world!",
}),
);
const output = await s3Client.send(
new GetObjectCommand({ Bucket: "foo-bucket", Key: "hello.txt" }),
);
console.log(await output.Body?.transformToString()); // "Hello, world!"
simSdk.restoreAll();

Intercepting a class affects every instance of that class. This includes clients created after the call to intercept. Intercepting an object affects that client only.

SimSdk replaces the intercepted client’s send method. It routes each command to the matching Yulin service and returns an SDK-shaped response. The request stays inside the process.

A client can have one active interception. A second interception throws SimSdkAlreadyInterceptedError and leaves the first one in place.

new SimSdk() creates a SimAws instance and exposes it as simSdk.simAws. Use that instance to prepare state before running the application, or to inspect state afterwards.

Pass an existing instance as new SimSdk({ simAws }) when several parts of a test need to share the same simulation.

Yulin resolves the account and region for every send call. The client’s region configuration selects the simulated region. Yulin uses its default region when the client has none.

The current simAws.runAs(...) caller selects the account. Without a runAs caller, Yulin uses the simulation’s default account and caller. Simulated IAM authorization applies to intercepted commands.

Use runAs to send commands as a role without changing the client or the application code:

/**
* Attributing intercepted SDK Commands to a caller with runAs.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import {
CreateBucketCommand,
ListBucketsCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { SimAws } from "@kensio/yulin";
import { SimSdk } from "@kensio/yulin/sdk";
const simAws = new SimAws();
const simSdk = new SimSdk({ simAws });
// Seed a Bucket, and a Role allowed to list Buckets, in a simulated Account.
const account = simAws.account("222222222222");
await account
.s3()
.createBucket(new CreateBucketCommand({ Bucket: "team-bucket" }));
await account.iam().createRole(
new CreateRoleCommand({
RoleName: "TeamRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: "arn:aws:iam::222222222222:root" },
Action: "sts:AssumeRole",
},
}),
}),
);
await account.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "TeamRole",
PolicyName: "list-buckets",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "s3:ListAllMyBuckets",
Resource: "*",
},
}),
}),
);
const s3Client = new S3Client({ region: "us-east-1" });
simSdk.intercept(s3Client);
await simAws.runAs(
{ kind: "arn", arn: "arn:aws:iam::222222222222:role/TeamRole" },
async () => {
// Sent as the TeamRole caller: resolved in Account 222222222222 and
// authorized against the Role's simulated IAM permissions.
const output = await s3Client.send(new ListBucketsCommand({}));
console.log(output.Buckets); // [{ Name: "team-bucket" }]
},
);
simSdk.restoreAll();

runAs applies only to the SimAws instance on which it was called. A caller set on another simulation does not affect these commands.

Restore an interception before later code needs the client’s original send method:

  • Save the result of simSdk.intercept(...) and call its restore() method to restore one client.
  • Call simSdk.restoreAll() to restore every client intercepted by that SimSdk.
  • Declare SimSdk or an interception handle with using to restore it when the scope ends.

A suite-wide interception stays installed until the test worker exits. Do not restore it in a per-file afterAll, since later files use the same interception and simulated state.

An interception handles every command by default. Pass an allow list when a test should accept only specific commands:

simSdk.intercept(s3Client, { commands: [GetObjectCommand] })

The list accepts command classes or command names. Sending another command throws SimSdkCommandNotInterceptedError.

The DynamoDB document client accepts plain JavaScript values. Intercept the document client object, then send @aws-sdk/lib-dynamodb commands through it:

/**
* An intercepted DynamoDB document client, writing plain JavaScript values.
*/
import { CreateTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
} from "@aws-sdk/lib-dynamodb";
import { SimSdk } from "@kensio/yulin/sdk";
using simSdk = new SimSdk();
const documents = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: "eu-west-2" }),
);
// The document client is what gets intercepted, not the client it was built
// from.
simSdk.intercept(documents);
// A document client forwards a Command it has no document form of, so the
// table is created through the same client.
await documents.send(
new CreateTableCommand({
TableName: "OrdersTable",
KeySchema: [{ AttributeName: "orderId", KeyType: "HASH" }],
AttributeDefinitions: [{ AttributeName: "orderId", AttributeType: "S" }],
BillingMode: "PAY_PER_REQUEST",
}),
);
await simSdk.simAws.backgroundTasksComplete();
await documents.send(
new PutCommand({
TableName: "OrdersTable",
Item: { orderId: "order-1", total: 42, paid: true },
}),
);
const read = await documents.send(
new GetCommand({ TableName: "OrdersTable", Key: { orderId: "order-1" } }),
);
console.log(read.Item?.["total"]); // 42
console.log(read.Item?.["paid"]); // true

DynamoDBDocumentClient.from(client) returns a separate client object. Intercept that object, not the base DynamoDBClient. You may intercept both clients. They use the same simulated tables when their account and region match.

Yulin converts document values at the interception boundary. It uses the default translation options from @aws-sdk/lib-dynamodb. The DynamoDB documentation lists the supported value conversions.

SDK interception supports these service clients:

  • ACM
  • API Gateway REST APIs and HTTP APIs
  • Athena
  • AWS Backup
  • Bedrock Runtime
  • CloudFormation
  • CloudFront and CloudFront KeyValueStore
  • CloudWatch metrics and CloudWatch Logs
  • Cognito Identity Provider
  • DynamoDB and DynamoDB Streams
  • ECS and Elastic Load Balancing v2
  • EventBridge and EventBridge Scheduler
  • Glue
  • IAM
  • Kinesis Data Firehose and Kinesis Data Streams
  • KMS
  • Lambda
  • Personalize, Personalize Events, and Personalize Runtime
  • Rekognition
  • Route 53
  • S3
  • Secrets Manager
  • SESv2
  • SNS and SQS
  • SSM
  • Step Functions
  • STS
  • WAFv2

Each service page lists the commands that service accepts. An unsupported command throws SimSdkUnsupportedCommandError and includes the supported command names. A client for an unknown service throws SimSdkUnknownServiceError.

  • Yulin intercepts client.send(command). SDK utilities that bypass send, including getSignedUrl, bypass interception. Paginators and waiters use send and can be intercepted. To use a presigned URL with Yulin, point the client at a served endpoint as shown in the S3 documentation.
  • Simulated errors have SDK-shaped name and $metadata fields. They are separate classes from the SDK exceptions, so match them by error.name instead of instanceof.
  • The callback form of send(command, callback) is not supported. Use the promise form.
  • Yulin reads the marshallOptions in DynamoDBDocumentClient.from(client, { marshallOptions, unmarshallOptions }) and ignores the unmarshallOptions. A stored value comes back the way a document client built with no options of its own reads it. See the DynamoDB docs.