AWS CDK Unit Testing Advanced Tips
In the previous article AWS CDK Unit Testing Guide: When and How to Use Different Test Types, I introduced basic concepts of unit testing in CDK, including when and how to use them.
In this article, I will introduce two advanced tips for CDK unit testing:
- Aligning feature flags with production environments
- Skipping bundling
Table of Contents
- AWS CDK Unit Testing Advanced Tips
- Table of Contents
- 1. Aligning Feature Flags with Production Environments
- 2. Skipping Bundling
- Conclusion
1. Aligning Feature Flags with Production Environments
What are Feature Flags?
AWS CDK uses feature flags to enable breaking changes with new functionality through an opt-in approach. This ensures existing CDK code behavior is preserved while allowing new CDK features to be adopted at your own pace. (For details, please refer to the official documentation.)
Feature flags can be configured in the context section within cdk.json.
(Additionally, you can set feature flags through other methods such as the context prop in App or Stack within CDK code, or via command line arguments like cdk deploy --context xx=yy.)
{
"context": {
"@aws-cdk/aws-iam:minimizePolicies": true
}
}
Feature flags are automatically added to cdk.json when creating a CDK project, i.e., when executing cdk init. Specifically, the feature flags introduced in CDK at that time are set with their default values.
Even when new functionality with feature flags is added to new CDK versions, those feature flags are generally not automatically added to existing CDK projects' cdk.json files. This means that upgrading CDK versions won't change the behavior of existing CDK code. (Rarely, some feature flags may be enabled by default.)
If you want to enable feature flags in existing projects, you must explicitly set the new feature flags in cdk.json yourself.
Feature Flag Behavior in Unit Tests
Feature flags are typically applied when configured in cdk.json.
However, in CDK unit tests using the Template class from the assertions module, cdk.json is not read.
This is because CDK is internally divided into CDK CLI and CDK App, with cdk.json being read by CDK CLI. However, unit tests call CDK App directly without going through CDK CLI. As a result, unit tests perform CDK synthesis without reading cdk.json.
Therefore, in unit tests, all feature flags enabled in cdk.json become disabled.
*Some feature flags are enabled by default even without explicit configuration.
Template Differences Caused by Feature Flag Discrepancies
As mentioned above, all feature flags become disabled when running unit tests. This means there may be configuration differences between templates actually deployed and templates generated in tests due to feature flags.
For example, suppose @aws-cdk/aws-iam:minimizePolicies feature flag is set to true in cdk.json.
Consider the following CDK code:
export class CdkSampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const role = new cdk.aws_iam.Role(this, 'Role', {
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
});
role.addToPrincipalPolicy(
new cdk.aws_iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: ['arn:aws:s3:::my-bucket/*'],
}),
);
role.addToPrincipalPolicy(
new cdk.aws_iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: ['arn:aws:s3:::my-bucket/*'],
}),
);
}
}
When @aws-cdk/aws-iam:minimizePolicies is true, CDK automatically merges multiple different actions for the same resource or multiple identical statements.
This means the above CDK code results in a single Action array containing both s3:GetObject and s3:PutObject:
"RoleDefaultPolicy5FFB7DAB": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Effect": "Allow",
"Resource": "arn:aws:s3:::my-bucket/*"
}
],
...
However, if you write a snapshot test like the following in unit tests:
const getTemplate = (): Template => {
const app = new App();
const stack = new CdkSampleStack(app, 'CdkSampleStack');
return Template.fromStack(stack);
};
test('Snapshot', () => {
const template = getTemplate();
expect(template.toJSON()).toMatchSnapshot();
});
The template generated by this test would look like this. Unlike the previous template, s3:GetObject and s3:PutObject are written as separate statements.
"RoleDefaultPolicy5FFB7DAB": {
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": "s3:GetObject",
"Effect": "Allow",
"Resource": "arn:aws:s3:::my-bucket/*",
},
{
"Action": "s3:PutObject",
"Effect": "Allow",
"Resource": "arn:aws:s3:::my-bucket/*",
},
],
...
},
"Type": "AWS::IAM::Policy",
},
While this may not be a significant difference, some feature flags can fundamentally change processing behavior, potentially causing unexpected differences in actual deployment results.
How to Align Feature Flags
Here's how to align feature flags between templates actually deployed and templates generated in tests.
When you want to align feature flags between production environment and tests, you need to explicitly pass context loaded from cdk.json to props of App etc.
Therefore, create a function like getContext below that reads the actual cdk.json and returns context. Then pass it to the context prop of App.
// Function that reads actual cdk.json and returns `context`
const getContext = (): Record<string, any> => {
const cdkJsonPath = path.join(__dirname, '..', 'cdk.json');
const cdkJson = JSON.parse(fs.readFileSync(cdkJsonPath, 'utf-8'));
return cdkJson.context || {};
};
const getTemplate = (): Template => {
const app = new App({
context: {
...getContext(), // Call it here
},
});
const stack = new CdkSampleStack(app, 'CdkSampleStack');
return Template.fromStack(stack);
};
This ensures the generated template matches what's actually deployed, improving test reliability.
"RoleDefaultPolicy5FFB7DAB": {
"Properties": {
"PolicyDocument": {
"Statement": [
{
- "Action": "s3:GetObject",
- "Effect": "Allow",
- "Resource": "arn:aws:s3:::my-bucket/*",
- },
- {
- "Action": "s3:PutObject",
+ "Action": [
+ "s3:GetObject",
+ "s3:PutObject",
+ ],
"Effect": "Allow",
"Resource": "arn:aws:s3:::my-bucket/*",
},
],
2. Skipping Bundling
About NodejsFunction Bundling
For example, there's a construct called NodejsFunction that automatically bundles TypeScript code to JavaScript and creates Lambda functions with Node.js runtime.
This construct uses esbuild for bundling when esbuild is available in your CDK project's node_modules (i.e., when esbuild is included in package.json's devDependencies).
*If esbuild is not available, or if the forceDockerBundling property in bundling is set to true, Docker bundling is executed. However, esbuild is more lightweight, so many people probably use esbuild.
export class CdkSampleStack2 extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new NodejsFunction(this, 'NodejsWithoutDocker', {
entry: path.join(__dirname, 'lambda', 'index.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_22_X,
});
}
}
How to Skip Bundling
In CDK unit tests, bundling of Lambda code like above is also performed.
However, if you're already testing Lambda code bundling in application tests, you might want to skip bundling in CDK tests to reduce test time.
Actually, you can skip bundling for all stacks by specifying an empty array for BUNDLING_STACKS in context as follows:
const getTemplate = (): Template => {
const app = new App({
context: {
[BUNDLING_STACKS]: [], // This
},
});
const stack = new CdkSampleStack(app, 'CdkSampleStack');
return Template.fromStack(stack);
};
test('Snapshot', () => {
const template = getTemplate();
expect(template.toJSON()).toMatchSnapshot();
});
However, bundling is not skipped when using Docker instead of esbuild. In such cases, you should use esbuild.
- esbuild is not installed
-
bundling.forceDockerBundlingis set totrue - Using
Code.fromDockerBuild()
export class CdkSampleStack2 extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new NodejsFunction(this, 'NodejsWithDocker', {
entry: path.join(__dirname, 'lambda', 'index.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_22_X,
bundling: {
forceDockerBundling: true,
},
});
new Function(this, 'FromDockerBuild', {
code: Code.fromDockerBuild(path.join(__dirname, '..')),
handler: 'handler',
runtime: Runtime.NODEJS_22_X,
});
}
}
Image Asset Building Does Not Run in Unit Tests
The above case was about Lambda code uploaded as file assets to S3 for use by Lambda. (runtime specifies the runtime corresponding to each code.)
On the other hand, when using image assets (Docker images) for Docker Lambda or ECS, Docker builds are not executed in unit tests at all.
Therefore, you don't need to specify BUNDLING_STACKS in such cases.
Code.fromAssetImage()DockerImageFunction-
DockerImageAsset
export class CdkSampleStack2 extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new Function(this, 'FromAssetImage', {
code: Code.fromAssetImage(path.join(__dirname, '..')),
handler: Handler.FROM_IMAGE,
runtime: Runtime.FROM_IMAGE,
});
new DockerImageFunction(this, 'DockerImageFunction', {
code: DockerImageCode.fromImageAsset(path.join(__dirname, '..')),
});
new DockerImageAsset(this, 'DockerImageAsset', {
directory: path.join(__dirname, '..'),
});
}
}
Here's a brief explanation of the reason.
Lambda code bundling runs within the construct, i.e., within CDK App. However, building image assets like Docker images runs in CDK CLI, not CDK App.
Also, CDK unit tests call CDK App directly without going through CDK CLI.
Therefore, image asset building performed by CDK CLI is not executed in unit tests.
Conclusion
I have introduced advanced tips for unit testing in AWS CDK.
Based on the basic tips article introduced at the beginning, please consider using these advanced tips as well.
Top comments (0)