Many teams only start to test CDK stacks after a deployment has gone wrong: the table suddenly disappeared because someone changed the removalPolicy, or the Lambda function was allowed to do anything in DynamoDB. A test would have caught both in a few milliseconds. What I like about the AWS CDK is that infrastructure is plain TypeScript code. Taken to its logical end, that also means this code deserves tests like any other code.
In this article, we build a small stack for a fictional city library together: reservations land in an SQS queue, a Lambda function writes them to DynamoDB, and broken messages move to a dead-letter queue. We test this stack with the aws-cdk-lib/assertions module and Vitest, entirely without an AWS account.
In short
The CDK turns your code into a CloudFormation template, and you check exactly that template with
Template.fromStackand targeted assertions, locally and within seconds. Tests pay off most for things that hurt when they break: deletion policies, IAM permissions, the DLQ and event source settings. The biggest pitfall is a snapshot test that turns red on every code change and therefore ends up being updated blindly.
What you will learn in this article
- Set up Vitest for CDK tests in TypeScript
- Check resources with hasResourceProperties, hasResource and Match
- Protect IAM permissions and references with Capture and getResourceId
- Build your own rules as an aspect and test them with Annotations
- Set up snapshot tests so that asset hashes do not get in the way
Why you should test CDK stacks
The AWS CDK is a generator. Your TypeScript code describes resources, and synthesis (“synth” for short) turns it into a CloudFormation template, a large JSON file that CloudFormation then deploys. A CDK test sits right in between: it synthesizes the stack inside the test process and checks the resulting template. You need no network, no credentials and no AWS account for that.
That sounds like checking for typos, but it does more. Constructs such as table.grantWriteData() create IAM policies behind the scenes, defaults change with feature flags, and a condition like isProd ? RETAIN : DESTROY is quickly flipped the wrong way. A test pins down what matters to you and speaks up as soon as a refactoring or a CDK update changes it.
What kinds of CDK tests are there?
The CDK documentation distinguishes two main kinds. I add a third one that I consider just as important in practice:
| Test type | What it checks | When I use it |
|---|---|---|
| Fine-grained assertions | Individual properties of individual resources | Always, for everything with real risk |
| Snapshot test | The whole template against a stored version | Sparingly, as a safety net during refactoring |
| Annotations | Errors and warnings reported by constructs or aspects | For team rules such as “every queue needs a DLQ” |
The sample stack and the places the tests protect.
Prerequisites
- Node.js 22 and npm
- Basic AWS CDK knowledge, for example from my article Deploy a Lambda function with CDK and TypeScript
- No AWS account for the tests. Only
cdk deployat the end needs credentials.
Test CDK stacks with Vitest, step by step
Every test follows the same flow. You create an App, add your stack to it, let Template.fromStack() build the template and check it. The animation shows the path from code to test result.
🎬 From CDK code to test result – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 1: Create the project
I start with an empty folder instead of cdk init so that you know every file. The versions are the ones I used to check everything.
mkdir cdk-stack-tests && cd cdk-stack-tests
npm init -y
npm pkg set type=module
mkdir bin lib src test
npm install -D aws-cdk-lib@2.275.0 constructs@10.8.1 aws-cdk@2.1148.0 \
vitest@5.1.0 typescript@7.0.2 esbuild@0.28.2 tsx \
@types/node@22 @types/aws-lambda @aws-sdk/client-dynamodb
echo '{ "app": "npx tsx bin/app.ts" }' > cdk.json
Then add the scripts to package.json. This is what the file looks like in the end:
{
"name": "cdk-stack-tests",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:update": "vitest run -u",
"typecheck": "tsc --noEmit",
"synth": "cdk synth -c stage=dev"
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "^3.1164.0",
"@types/aws-lambda": "^8.10.164",
"@types/node": "^22.20.5",
"aws-cdk": "^2.1148.0",
"aws-cdk-lib": "^2.275.0",
"constructs": "^10.8.1",
"esbuild": "^0.28.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.1.0"
}
}
Add a lean tsconfig.json. Since the project uses ES modules, it is set to NodeNext:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true
},
"include": ["bin", "lib", "src", "test"]
}
In vitest.config.ts, I raise the timeout because NodejsFunction bundles the handler with esbuild during synthesis. I also register a snapshot serializer, which we will get to in step 8.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 30_000,
snapshotSerializers: ['./test/mask-asset-hashes.ts'],
},
});
Step 2: The sample stack
The stack gets a stage property. In prod, the table survives the deletion of the stack and has point-in-time recovery (PITR), a continuous backup from which you can restore any moment of the last few days. In dev, everything may go.
// lib/library-stack.ts
import { Aspects, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import type { Construct } from 'constructs';
import { fileURLToPath } from 'node:url';
import { QueuesNeedDeadLetterQueue } from './dlq-check.js';
export interface LibraryStackProps extends StackProps {
stage: 'dev' | 'prod';
}
export class LibraryStack extends Stack {
constructor(scope: Construct, id: string, props: LibraryStackProps) {
super(scope, id, props);
const isProd = props.stage === 'prod';
const table = new dynamodb.Table(this, 'ReservationsTable', {
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: isProd },
removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
});
const deadLetterQueue = new sqs.Queue(this, 'ReservationsDlq', {
retentionPeriod: Duration.days(14),
});
const queue = new sqs.Queue(this, 'ReservationsQueue', {
visibilityTimeout: Duration.seconds(60),
deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: 3 },
});
const reserveFn = new NodejsFunction(this, 'ReserveBookFn', {
entry: fileURLToPath(new URL('../src/reserve.ts', import.meta.url)),
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
timeout: Duration.seconds(10),
memorySize: 256,
environment: { TABLE_NAME: table.tableName },
logGroup: new logs.LogGroup(this, 'ReserveBookLogs', {
retention: isProd ? logs.RetentionDays.ONE_MONTH : logs.RetentionDays.ONE_WEEK,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
table.grantWriteData(reserveFn);
reserveFn.addEventSource(new SqsEventSource(queue, {
batchSize: 10,
reportBatchItemFailures: true,
}));
Aspects.of(this).add(new QueuesNeedDeadLetterQueue());
}
}
The handler is deliberately small. It writes one item per message and reports failures individually via batchItemFailures. I explain how that works in detail in the article SQS partial batch response.
// src/reserve.ts
import type { SQSBatchResponse, SQSEvent } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
const db = new DynamoDBClient({});
const tableName = process.env.TABLE_NAME ?? '';
// Stores one reservation per SQS message and reports failed messages individually.
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];
for (const record of event.Records) {
try {
const { bookId, memberId } = JSON.parse(record.body) as { bookId: string; memberId: string };
await db.send(new PutItemCommand({
TableName: tableName,
Item: { pk: { S: `BOOK#${bookId}` }, sk: { S: `MEMBER#${memberId}` } },
ConditionExpression: 'attribute_not_exists(pk)',
}));
} catch {
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
For testability, the file bin/app.ts matters: it is the only place where a stack for deployment is created. The tests import only the LibraryStack class and build their own App. The CDK documentation recommends this too, so that a test run never creates anything real by accident.
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { LibraryStack } from '../lib/library-stack.js';
const app = new App();
const stage = app.node.tryGetContext('stage') === 'prod' ? 'prod' : 'dev';
new LibraryStack(app, `Library-${stage}`, { stage });
Step 3: Create the template in the test
All tests go into test/library-stack.test.ts. At the top, a small helper function builds the stack for a stage and returns the template. Because bundling the handler takes a moment, I create the prod template only once in beforeAll.
// test/library-stack.test.ts
import { App, Aspects, Stack } from 'aws-cdk-lib';
import { Annotations, Capture, Match, Template } from 'aws-cdk-lib/assertions';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { beforeAll, describe, expect, test } from 'vitest';
import { QueuesNeedDeadLetterQueue } from '../lib/dlq-check.js';
import { LibraryStack } from '../lib/library-stack.js';
// IAM allows "Action" as a single string or as a list.
function actionsOf(capture: Capture): string[] {
try {
return capture.asArray() as string[];
} catch {
return [capture.asString()];
}
}
function synth(stage: 'dev' | 'prod') {
const app = new App();
const stack = new LibraryStack(app, `Library-${stage}`, { stage });
return { stack, template: Template.fromStack(stack) };
}
describe('LibraryStack (prod)', () => {
let template: Template;
beforeAll(() => {
({ template } = synth('prod'));
});
Step 4: Check properties with hasResourceProperties
resourceCountIs counts resources of one type. hasResourceProperties looks for a resource whose Properties match your pattern. The comparison is partial: anything you leave out may have any value. This keeps the test stable when the CDK later sets an additional property.
// test/library-stack.test.ts (continued)
test('creates exactly one Lambda function and two queues', () => {
template.resourceCountIs('AWS::Lambda::Function', 1);
template.resourceCountIs('AWS::SQS::Queue', 2);
});
test('Lambda runs on Node.js 22, arm64, with a 10 second timeout', () => {
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: 'nodejs22.x',
Architectures: ['arm64'],
Timeout: 10,
Environment: {
Variables: { TABLE_NAME: { Ref: Match.stringLikeRegexp('^ReservationsTable') } },
},
});
});
In the template, the table name is not a string but a Ref to the logical ID of the table. That ID carries a hash suffix such as ReservationsTableD1629A9C. With Match.stringLikeRegexp, I only check the readable beginning, so the test does not break if the construct path changes one day.
Step 5: Check deletion policies with hasResource
In the template, the DeletionPolicy sits next to Properties, not inside. That is why hasResourceProperties cannot see it. For this, there is hasResource, which compares the entire resource definition. I consider this the most important test in the whole project, because it protects the data in production.
// test/library-stack.test.ts (continued)
test('table is retained and has point-in-time recovery', () => {
template.hasResource('AWS::DynamoDB::Table', {
DeletionPolicy: 'Retain',
UpdateReplacePolicy: 'Retain',
Properties: {
BillingMode: 'PAY_PER_REQUEST',
PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
},
});
});
Step 6: Check references with getResourceId
There are two queues in the stack, and the right one has to be the DLQ of the other. With getResourceId, you get the logical ID of exactly one resource that matches a pattern. I recognize the DLQ by its retention period of 14 days and then check that the main queue points to it via Fn::GetAtt. The same block holds the test for the event source mapping: without ReportBatchItemFailures, Lambda ignores what the handler returns, and the handler's unit test never notices.
// test/library-stack.test.ts (continued)
test('queue sends messages to the DLQ after three attempts', () => {
const dlqId = template.getResourceId('AWS::SQS::Queue', {
Properties: { MessageRetentionPeriod: 1209600 },
});
template.hasResourceProperties('AWS::SQS::Queue', {
RedrivePolicy: {
deadLetterTargetArn: { 'Fn::GetAtt': [dlqId, 'Arn'] },
maxReceiveCount: 3,
},
});
});
test('event source mapping reports partial batch failures', () => {
template.hasResourceProperties('AWS::Lambda::EventSourceMapping', {
BatchSize: 10,
FunctionResponseTypes: ['ReportBatchItemFailures'],
});
});
Step 7: Protect IAM permissions with Capture
Now it gets interesting. Match.arrayWith checks whether an array contains certain elements, in the given order. I use it to make sure that dynamodb:PutItem is allowed. For the opposite direction, “nothing more than needed”, a matcher is not enough. So I collect all actions from all statements with Capture. If several places match, you page through the hits with next().
// test/library-stack.test.ts (continued)
test('Lambda may write to the table but has no wildcard DynamoDB rights', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Action: Match.arrayWith(['dynamodb:PutItem']),
Effect: 'Allow',
}),
]),
},
});
const actions = new Capture();
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: { Statement: Match.arrayWith([Match.objectLike({ Action: actions })]) },
});
const allActions: string[] = [];
do {
allActions.push(...actionsOf(actions));
} while (actions.next());
expect(allActions).toContain('dynamodb:PutItem');
expect(allActions).not.toContain('dynamodb:*');
expect(allActions).not.toContain('dynamodb:Scan');
});
If someone later uses grantFullAccess instead of grantWriteData for convenience, this test turns red. Exactly this kind of change slips through code review easily, because it is a single word in the diff.
Step 8: Snapshots without asset hash noise
A snapshot test stores the complete template on its first run and compares every new template with it from then on. The problem: the CDK stores the bundled handler as an asset and writes its SHA-256 hash into the S3 key of the function. Change one line in the handler, and the hash changes, so the snapshot turns red although the infrastructure is the same. After a few of these, people start pressing “update snapshot” by reflex, and then the test is worthless.
My solution is a small serializer that replaces every 64-character hex hash with a placeholder before the comparison:
// test/mask-asset-hashes.ts
import type { SnapshotSerializer } from 'vitest';
// Replaces 64-character asset hashes so a change in the handler code does not break the snapshot.
const ASSET_HASH = /[0-9a-f]{64}/g;
const serializer: SnapshotSerializer = {
test: (value) => typeof value === 'string' && /[0-9a-f]{64}/.test(value),
serialize: (value: string, _config, _indentation, _depth, _refs, printer) =>
printer(value.replace(ASSET_HASH, '[ASSET-HASH]'), _config, _indentation, _depth, _refs),
};
export default serializer;
I show the test itself together with the checks for the dev stage. With test.each, you check the same property for several stages without copying the test:
// test/library-stack.test.ts (continued)
test('template matches the snapshot', () => {
expect(template.toJSON()).toMatchSnapshot();
});
});
describe('LibraryStack (dev)', () => {
test('table is deleted with the stack and has no point-in-time recovery', () => {
const { template } = synth('dev');
template.hasResource('AWS::DynamoDB::Table', {
DeletionPolicy: 'Delete',
Properties: {
PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: false },
},
});
});
test.each([
['dev', 7],
['prod', 30],
] as const)('log retention in %s is %i days', (stage, days) => {
synth(stage).template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: days });
});
});
Step 9: Test your own rules as an aspect
Some rules apply to every stack in a team, for example: every SQS queue needs a dead-letter queue. Instead of checking this rule again in every test, I build an aspect. An aspect is a visitor that the CDK runs over all constructs in a scope during synthesis, after the rest of your code has finished. If it finds a violation, it attaches an error via Annotations, and cdk synth stops.
// lib/dlq-check.ts
import { Annotations, Stack, type IAspect } from 'aws-cdk-lib';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import type { IConstruct } from 'constructs';
// Reports an error for every SQS queue that neither has a dead-letter queue nor is one.
export class QueuesNeedDeadLetterQueue implements IAspect {
visit(node: IConstruct): void {
if (!(node instanceof Stack)) return;
const queues = node.node.findAll().filter((c): c is sqs.Queue => c instanceof sqs.Queue);
const deadLetterQueues = new Set(queues.map((q) => q.deadLetterQueue?.queue).filter(Boolean));
for (const queue of queues) {
if (!queue.deadLetterQueue && !deadLetterQueues.has(queue)) {
Annotations.of(queue).addError('SQS queue has no dead-letter queue (DLQ).');
}
}
}
}
The aspect gets two tests: in the real stack, it must report nothing; in a playground stack with a bare queue, it has to fire. Annotations.fromStack() gives you hasError, hasNoError and the matching counterparts for warnings.
// test/library-stack.test.ts (continued)
describe('QueuesNeedDeadLetterQueue', () => {
test('no errors in LibraryStack', () => {
const { stack } = synth('prod');
Annotations.fromStack(stack).hasNoError('*', Match.anyValue());
});
test('reports a queue without a DLQ as an error', () => {
const app = new App();
const stack = new Stack(app, 'Playground');
new sqs.Queue(stack, 'OrphanQueue');
Aspects.of(stack).add(new QueuesNeedDeadLetterQueue());
Annotations.fromStack(stack).hasError(
'/Playground/OrphanQueue',
Match.stringLikeRegexp('no dead-letter queue'),
);
});
});
Step 10: Run the tests
# check types
npx tsc --noEmit
# run the tests (the first run writes the snapshot)
npm test
# synthesize the dev template locally, without deploying
npx cdk synth -c stage=dev
# renew the snapshot after an intended change
npm run test:update
Expected result: Vitest bundles the handler a few times (the “Bundling asset” lines are normal) and reports twelve passing tests and one written snapshot at the end. In the test run for this article, that took just under four seconds. Commit the test/__snapshots__ folder, otherwise the snapshot has nothing to compare against on the build server.
The tests cost nothing, as they run entirely locally. If you want to deploy the stack with npx cdk deploy -c stage=dev, you need an account prepared with cdk bootstrap. All resources in the example are billed by usage; only point-in-time recovery in prod adds a charge per stored gigabyte. Give the deploying user no more permissions than needed, and tear test environments down again with npx cdk destroy.
As a trial, set reportBatchItemFailures to false in lib/library-stack.ts. Two tests then fail, and the message shows you precisely what is missing:
FAIL test/library-stack.test.ts > LibraryStack (prod) > event source mapping reports partial batch failures
AssertionError: Template has 1 resources with type AWS::Lambda::EventSourceMapping, but none match as expected.
The 1 closest matches:
ReserveBookFnSqsEventSourceLibraryprodReservationsQueue326A39A4A230D9C9 :: {
"Properties": {
"BatchSize": 10,
"EventSourceArn": { "Fn::GetAtt": [ "ReservationsQueue8D73AA37", "Arn" ] },
"FunctionName": { "Ref": "ReserveBookFn587EBB07" },
!! Missing key 'FunctionResponseTypes'
"FunctionResponseTypes": undefined
},
"Type": "AWS::Lambda::EventSourceMapping"
}
Common mistakes when testing CDK stacks
The stack is created on import. If new LibraryStack(app, …) sits in the same file as the class, that code runs on every test import. Separate the class and the entry point as shown above in lib/ and bin/.
Only snapshots, no targeted tests. A red snapshot tells you that something changed, but not whether that is bad. The CDK docs also advise against relying on snapshots alone. I use exactly one snapshot per stack and, on top of that, targeted assertions for everything that really counts.
Hard-coded logical IDs. If you write ReservationsTableD1629A9C literally into a test, it turns red as soon as someone renames or moves a construct. Use Match.stringLikeRegexp or getResourceId.
Wrong method for DeletionPolicy. hasResourceProperties never finds DeletionPolicy, because it sits outside Properties. For everything next to the properties, such as DependsOn, you need hasResource.
No lock file. During synthesis, NodejsFunction looks for a package-lock.json (or the lock file of pnpm, Yarn or Bun). Without it, the very first test fails with CannotFindPackageLockFile. Run npm install once and commit the file, or set the path with depsLockFilePath.
Order in arrayWith. Match.arrayWith(['a', 'b']) does not match if the template lists b before a. Check single elements instead, or collect the values with Capture.
My take: what template tests cannot do
For me, template tests belong in every CDK project that lives longer than one sprint. Still, it is worth being honest about what they can do. A template test checks what you send to CloudFormation, not what AWS makes of it. Whether the deployment fails on a quota, whether a service control policy blocks your role, or whether the Lambda function can really write to the table at runtime: no template test sees that. For those questions, you also need a deployment to a separate test environment and a few integration tests.
I also disagree with the common view that CDK tests have to cover every resource. A test for MemorySize: 256 only repeats the code and gets in the way of every adjustment. Test the things whose failure would wake you up at night: deletion policies, permissions, error paths and encryption. In a typical stack, that is five to ten tests, and you can write them in a morning. Together with the Lambda unit tests with Vitest for the handlers, both halves of your Serverless application are covered.
If you want the most important CDK commands and constructs at hand, take a look at my AWS CDK cheat sheet.
If you also want to check security rules in the same Vitest setup, take a look at my article cdk-nag v3: Security Checks in CDK Tests with Vitest.
FAQ
Do I need an AWS account for CDK tests?
No. Template.fromStack synthesizes the stack locally. As long as you do not use lookups such as Vpc.fromLookup, which read context values from the account, the tests run without credentials and without a network.
What is the difference between hasResource and hasResourceProperties?
hasResourceProperties compares only the Properties section of a resource. hasResource compares the whole resource definition and therefore also sees DeletionPolicy, UpdateReplacePolicy and DependsOn.
Jest or Vitest for AWS CDK tests?
Both work, since aws-cdk-lib/assertions throws ordinary errors and is not tied to a test framework. cdk init sets up Jest. For new TypeScript projects with ES modules, I pick Vitest because it needs no extra transformers.
Why does my CDK snapshot test fail after every code change?
Usually because of asset hashes. Every change to the Lambda code produces a new hash in the S3 key of the function. Replace these hashes with a placeholder using a snapshot serializer, as shown in step 8.
Your next step
Take your most important stack and write exactly one test: the one for the resource whose loss would hurt most. Usually that is a table or a bucket with DeletionPolicy: Retain. IAM and the DLQ follow almost by themselves. Which rule would you turn into an aspect first in your team?
Working with the Serverless Framework instead of CDK? I show how to check the generated template with Vitest in Testing the Serverless Framework with Vitest.
Sources
- AWS CDK Developer Guide: Test AWS CDK applications
- AWS CDK API Reference: aws-cdk-lib.assertions (Template, Match, Capture, Annotations)
- AWS CDK Developer Guide: Aspects
- Vitest: snapshot tests and custom serializers
- Checked on 11/09/2026 with Node.js 22.23, aws-cdk-lib 2.275.0, aws-cdk 2.1148.0, Vitest 5.1.0 and TypeScript 7.0.2 (12 tests passed,
tsc --noEmitwithout errors,cdk synthfor dev and prod successful). Not deployed.
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.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
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)