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.fromStack and 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”

Test CDK stacks example: SQS queue with DLQ, Lambda and DynamoDB table, with the settings checked by tests marked

The sample stack and the places the tests protect.

Prerequisites

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'); expectSource:https://dev.to/sascha-ms/test-cdk-stacks-template-assertions-with-vitest-15ng

Related posts