This is the AWS CDK v2 Developer Guide. The older CDK v1 entered maintenance on June 1, 2022 and ended support on June 1, 2023.
With the AWS CDK, your infrastructure can be as testable as any other code you write. You can test in the cloud and locally.
This topic addresses how to test in the cloud. For guidance on local testing see Locally test and build AWS CDK applications with the AWS SAM CLI. The standard approach to testing
AWS CDK apps uses the AWS CDK's assertions module and popular test frameworks like Jest
There are two categories of tests that you can write for AWS CDK apps.
-
Fine-grained assertions test specific aspects of the generated AWS CloudFormation template, such as "this resource has this property with this value." These tests can detect regressions. They're also useful when you're developing new features using test-driven development. (You can write a test first, then make it pass by writing a correct implementation.) Fine-grained assertions are the most frequently used tests.
-
Snapshot tests test the synthesized AWS CloudFormation template against a previously stored baseline template. Snapshot tests let you refactor freely, since you can be sure that the refactored code works exactly the same way as the original. If the changes were intentional, you can accept a new baseline for future tests. However, CDK upgrades can also cause synthesized templates to change, so you can't rely only on snapshots to make sure that your implementation is correct.
Note
Complete versions of the TypeScript, Python, and Java apps used as examples in this topic are available on GitHub
Getting started
To illustrate how to write these tests, we'll create a stack that contains an AWS Step Functions state machine and an AWS Lambda function. The Lambda function is subscribed to an Amazon SNS topic and simply forwards the message to the state machine.
First, create an empty CDK application project using the CDK Toolkit and installing the libraries we'll need. The constructs we'll use are all in the main CDK package, which is a default dependency in projects created with the CDK Toolkit. However, you must install your testing framework.
$
mkdir state-machine && cd state-machine cdk init --language=typescript npm install --save-dev jest @types/jest
Create a directory for your tests.
$
mkdir test
Edit the project's package.json
to tell NPM how to run Jest, and to tell Jest what kinds
of files to collect. The necessary changes are as follows.
-
Add a new
test
key to thescripts
section -
Add Jest and its types to the
devDependencies
section -
Add a new
jest
top-level key with amoduleFileExtensions
declaration
These changes are shown in the following outline. Place the new text where indicated in
package.json
. The "..." placeholders indicate existing parts of the file that should not
be changed.
{
...
"scripts": {
...
"test": "jest"
},
"devDependencies": {
...
"@types/jest": "^24.0.18",
"jest": "^24.9.0"
},
"jest": {
"moduleFileExtensions": ["js"]
}
}
The example stack
Here's the stack that will be tested in this topic. As we've previously described, it contains a Lambda function and a Step Functions state machine, and accepts one or more Amazon SNS topics. The Lambda function is subscribed to the Amazon SNS topics and forwards them to the state machine.
You don't have to do anything special to make the app testable. In fact, this CDK stack is not different in any important way from the other example stacks in this Guide.
Place the following code in lib/state-machine-stack.ts
:
import * as cdk from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import * as sns_subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as sfn from "aws-cdk-lib/aws-stepfunctions";
import { Construct } from "constructs";
export interface StateMachineStackProps extends cdk.StackProps {
readonly topics: sns.Topic[];
}
export class StateMachineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: StateMachineStackProps) {
super(scope, id, props);
// In the future this state machine will do some work...
const stateMachine = new sfn.StateMachine(this, "StateMachine", {
definition: new sfn.Pass(this, "StartState"),
});
// This Lambda function starts the state machine.
const func = new lambda.Function(this, "LambdaFunction", {
runtime: lambda.Runtime.NODEJS_18_X,
handler: "handler",
code: lambda.Code.fromAsset("./start-state-machine"),
environment: {
STATE_MACHINE_ARN: stateMachine.stateMachineArn,
},
});
stateMachine.grantStartExecution(func);
const subscription = new sns_subscriptions.LambdaSubscription(func);
for (const topic of props.topics) {
topic.addSubscription(subscription);
}
}
}
We'll modify the app's main entry point so that we don't actually instantiate our stack. We don't want to accidentally deploy it. Our tests will create an app and an instance of the stack for testing. This is a useful tactic when combined with test-driven development: make sure that the stack passes all tests before you enable deployment.
In bin/state-machine.ts
:
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
const app = new cdk.App();
// Stacks are intentionally not created here -- this application isn't meant to
// be deployed.
The Lambda function
Our example stack includes a Lambda function that starts our state machine. We must provide the source code for this function so the CDK can bundle and deploy it as part of creating the Lambda function resource.
-
Create the folder
start-state-machine
in the app's main directory. -
In this folder, create at least one file. For example, you can save the following code in
start-state-machines/index.js
.exports.handler = async function (event, context) { return 'hello world'; };
However, any file will work, since we won't actually be deploying the stack.
Running tests
For reference, here are the commands you use to run tests in your AWS CDK app. These are the same commands that you'd use to run the tests in any project using the same testing framework. For languages that require a build step, include that to make sure that your tests have compiled.
$
tsc && npm test
Fine-grained assertions
The first step for testing a stack with fine-grained assertions is to synthesize the stack, because we're writing assertions against the generated AWS CloudFormation template.
Our StateMachineStackStack
requires that we pass it the Amazon SNS topic to be forwarded to the state
machine. So in our test, we'll create a separate stack to contain the topic.
Ordinarily, when writing a CDK app, you can subclass Stack
and instantiate the Amazon SNS topic in
the stack's constructor. In our test, we instantiate Stack
directly, then pass this stack as the
Topic
's scope, attaching it to the stack. This is functionally equivalent and less verbose. It also
helps make stacks that are used only in tests "look different" from the stacks that you intend to deploy.
import { Capture, Match, Template } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import { StateMachineStack } from "../lib/state-machine-stack";
describe("StateMachineStack", () => {
test("synthesizes the way we expect", () => {
const app = new cdk.App();
// Since the StateMachineStack consumes resources from a separate stack
// (cross-stack references), we create a stack for our SNS topics to live
// in here. These topics can then be passed to the StateMachineStack later,
// creating a cross-stack reference.
const topicsStack = new cdk.Stack(app, "TopicsStack");
// Create the topic the stack we're testing will reference.
const topics = [new sns.Topic(topicsStack, "Topic1", {})];
// Create the StateMachineStack.
const stateMachineStack = new StateMachineStack(app, "StateMachineStack", {
topics: topics, // Cross-stack reference
});
// Prepare the stack for assertions.
const template = Template.fromStack(stateMachineStack);
}
Now we can assert that the Lambda function and the Amazon SNS subscription were created.
// Assert it creates the function with the correct properties...
template.hasResourceProperties("AWS::Lambda::Function", {
Handler: "handler",
Runtime: "nodejs14.x",
});
// Creates the subscription...
template.resourceCountIs("AWS::SNS::Subscription", 1);
Our Lambda function test asserts that two particular properties of the function resource have specific values. By
default, the hasResourceProperties
method performs a partial match on the resource's properties as given
in the synthesized CloudFormation template. This test requires that the provided properties exist and have the specified
values, but the resource can also have other properties, which are not tested.
Our Amazon SNS assertion asserts that the synthesized template contains a subscription, but nothing about the
subscription itself. We included this assertion mainly to illustrate how to assert on resource counts. The
Template
class offers more specific methods to write assertions against the Resources
,
Outputs
, and Mapping
sections of the CloudFormation template.
Matchers
The default partial matching behavior of hasResourceProperties
can be changed using
matchers from the Match
class.
Matchers range from lenient (Match.anyValue
) to strict (Match.objectEquals
). They can
be nested to apply different matching methods to different parts of the resource properties. Using
Match.objectEquals
and Match.anyValue
together, for example, we can test the state
machine's IAM role more fully, while not requiring specific values for properties that may change.
// Fully assert on the state machine's IAM role with matchers.
template.hasResourceProperties(
"AWS::IAM::Role",
Match.objectEquals({
AssumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "sts:AssumeRole",
Effect: "Allow",
Principal: {
Service: {
"Fn::Join": [
"",
["states.", Match.anyValue(), ".amazonaws.com"],
],
},
},
},
],
},
})
);
Many CloudFormation resources include serialized JSON objects represented as strings. The
Match.serializedJson()
matcher can be used to match properties inside this JSON.
For example, Step Functions state machines are defined using a string in the JSON-based Amazon States Language. We'll use
Match.serializedJson()
to make sure that our initial state is the only step. Again, we'll use nested
matchers to apply different kinds of matching to different parts of the object.
// Assert on the state machine's definition with the Match.serializedJson()
// matcher.
template.hasResourceProperties("AWS::StepFunctions::StateMachine", {
DefinitionString: Match.serializedJson(
// Match.objectEquals() is used implicitly, but we use it explicitly
// here for extra clarity.
Match.objectEquals({
StartAt: "StartState",
States: {
StartState: {
Type: "Pass",
End: true,
// Make sure this state doesn't provide a next state -- we can't
// provide both Next and set End to true.
Next: Match.absent(),
},
},
})
),
});
Capturing
It's often useful to test properties to make sure they follow specific formats, or have the same value as another
property, without needing to know their exact values ahead of time. The assertions
module provides this
capability in its Capture
class.
By specifying a Capture
instance in place of a value in hasResourceProperties
, that
value is retained in the Capture
object. The actual captured value can be retrieved using the object's
as
methods, including asNumber()
, asString()
, and asObject
, and
subjected to test. Use Capture
with a matcher to specify the exact location of the value to be captured
within the resource's properties, including serialized JSON properties.
The following example tests to make sure that the starting state of our state machine has a name beginning with
Start
. It also tests that this state is present within the list of states in the machine.
// Capture some data from the state machine's definition.
const startAtCapture = new Capture();
const statesCapture = new Capture();
template.hasResourceProperties("AWS::StepFunctions::StateMachine", {
DefinitionString: Match.serializedJson(
Match.objectLike({
StartAt: startAtCapture,
States: statesCapture,
})
),
});
// Assert that the start state starts with "Start".
expect(startAtCapture.asString()).toEqual(expect.stringMatching(/^Start/));
// Assert that the start state actually exists in the states object of the
// state machine definition.
expect(statesCapture.asObject()).toHaveProperty(startAtCapture.asString());
Snapshot tests
In snapshot testing, you compare the entire synthesized CloudFormation template against a previously stored baseline (often called a "master") template. Unlike fine-grained assertions, snapshot testing isn't useful in catching regressions. This is because snapshot testing applies to the entire template, and things besides code changes can cause small (or not-so-small) differences in synthesis results. These changes may not even affect your deployment, but they will still cause a snapshot test to fail.
For example, you might update a CDK construct to incorporate a new best practice, which can cause changes to the synthesized resources or how they're organized. Alternatively, you might update the CDK Toolkit to a version that reports additional metadata. Changes to context values can also affect the synthesized template.
Snapshot tests can be of great help in refactoring, though, as long as you hold constant all other factors that might affect the synthesized template. You will know immediately if a change you made has unintentionally changed the template. If the change is intentional, simply accept the new template as the baseline.
For example, if we have this DeadLetterQueue
construct:
export class DeadLetterQueue extends sqs.Queue {
public readonly messagesInQueueAlarm: cloudwatch.IAlarm;
constructor(scope: Construct, id: string) {
super(scope, id);
// Add the alarm
this.messagesInQueueAlarm = new cloudwatch.Alarm(this, 'Alarm', {
alarmDescription: 'There are messages in the Dead Letter Queue',
evaluationPeriods: 1,
threshold: 1,
metric: this.metricApproximateNumberOfMessagesVisible(),
});
}
}
We can test it like this:
import { Match, Template } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { DeadLetterQueue } from "../lib/dead-letter-queue";
describe("DeadLetterQueue", () => {
test("matches the snapshot", () => {
const stack = new cdk.Stack();
new DeadLetterQueue(stack, "DeadLetterQueue");
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
});
Tips for tests
Remember, your tests will live just as long as the code they test, and they will be read and modified just as often. Therefore, it pays to take a moment to consider how best to write them.
Don't copy and paste setup lines or common assertions. Instead, refactor this logic into fixtures or helper functions. Use good names that reflect what each test actually tests.
Don't try to do too much in one test. Preferably, a test should test one and only one behavior. If you accidentally break that behavior, exactly one test should fail, and the name of the test should tell you what failed. This is more an ideal to be striven for, however; sometimes you will unavoidably (or inadvertently) write tests that test more than one behavior. Snapshot tests are, for reasons we've already described, especially prone to this problem, so use them sparingly.