38.7 CDK Testing: Unit and Integration Tests with assertions Library
Right, testing. The part we all love to skip, right up until our entire cloud formation explodes at 3 AM because we typoed a bucket policy. Let’s be honest, testing infrastructure code feels a bit like trying to nail jelly to a wall—it’s messy, it’s abstract, and traditional unit tests don’t quite cut it. The CDK team felt your pain, and they shipped a @aws-cdk/assertions library (now largely superseded by the aws-cdk-lib/assertions module) to give you the tools to do this properly. Think of it less like testing functions and more like testing blueprints. You’re not checking if a hammer swing is correct; you’re checking if the architect’s drawing specifies a load-bearing wall.
The core concept is template-based testing. When you run cdk synth, your code produces a CloudFormation template—a big JSON object that describes your desired infrastructure state. The assertions library lets you say, “Hey, does this synthesized template contain the resources I expect, with the exact properties I intended?” This is your first and most powerful line of defense.
The Basic Unit Test: HaveResource
Let’s start with the workhorse: Template.fromStack(stack).hasResourceProperties(). This doesn’t just check if a resource exists; it checks if a specific resource type exists with a specific set of properties. This is crucial because CDK adds a lot of default properties you didn’t explicitly set.
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';
import * as s3 from 'aws-cdk-lib/aws-s3';
test('S3 Bucket Created with Correct Properties', () => {
// Arrange: Create the stack
const app = new cdk.App();
const stack = new MyStack(app, 'TestStack');
// Act: Synthesize the template
const template = Template.fromStack(stack);
// Assert: Check the template for a specific resource
template.hasResourceProperties('AWS::S3::Bucket', {
BucketName: 'my-super-unique-bucket-name',
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
BlockPublicPolicy: true,
IgnorePublicAcls: true,
RestrictPublicBuckets: true,
},
// Notice we DON'T check VersioningConfiguration here.
// The test will only validate the properties we list.
});
});
Why is this better than checking the object returned by new Bucket()? Because that object is a CDK construct, not the actual cloud resource. This test proves that the final, rendered CloudFormation template—the thing that will actually be deployed—is correct. It catches errors introduced by misconfigured props, custom resources, or even wonky logic in your construct.
Testing for Absence and Counting
Sometimes, you need to ensure something isn’t created. Like a wildly permissive IAM policy. The designers, in their infinite wisdom, made this a bit counter-intuitive. You don’t get a .hasNoResource() method. Instead, you use resourceCountIs() and expect zero.
test('No Death Star Thermal Exhaust Port is Created', () => {
const app = new cdk.App();
const stack = new DeathStarStack(app, 'TestDeathStar');
const template = Template.fromStack(stack);
// This is how you assert something does NOT exist.
template.resourceCountIs('AWS::DeathStar::ThermalExhaustPort', 0);
// And while we're at it, let's make sure we have exactly one main reactor.
template.resourceCountIs('AWS::DeathStar::MainReactor', 1);
});
The Pitfall of Logical IDs: Use findResources
The biggest “gotcha” is CloudFormation Logical IDs. By default, CDK generates them with a long hash prefix (e.g., Bucket83908E77) to ensure uniqueness. If you test for this exact ID, your tests become incredibly brittle. The assertions library is designed to work around this. You should almost never need to assert on a specific Logical ID. Instead, use the property-based matching shown above.
However, if you must (because you’re using exportValue or some other explicit reference), you can use findResources to get the raw template and inspect it.
test('Output Exports Correct Logical ID', () => {
const stack = new cdk.Stack();
const bucket = new s3.Bucket(stack, 'MyBucket');
new cdk.CfnOutput(stack, 'BucketArnOutput', {
value: bucket.bucketArn,
exportName: 'MySpecialBucketArn',
});
const template = Template.fromStack(stack);
const outputs = template.findOutputs('*'); // Get all outputs
// This is messy and brittle. Avoid it if you can.
// You're now searching for a pattern in the generated ID.
expect(outputs).toEqual(expect.objectContaining({
BucketArnOutput: {
Value: { 'Fn::GetAtt': [ expect.stringMatching(/^Bucket[A-Z0-9]+/), 'Arn' ] },
Export: { Name: 'MySpecialBucketArn' }
}
}));
});
Integration Testing: hasResource and Resource Matching
Unit tests are for checking the what. Integration tests are for checking the connections. This is where you ensure that your Lambda function has an Invoke permission on your DynamoDB table, or that your EC2 instance’s security group allows port 22. You do this by matching on the entire resource definition, not just its properties. The hasResource method is your friend here, as it can match on deeper structures like DependsOn.
test('Lambda Has Invoke Permission on DynamoDB Table', () => {
const stack = new MyStack(app, 'TestStack');
const template = Template.fromStack(stack);
// hasResourceProperties is often enough for permissions...
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Action: 'dynamodb:PutItem',
Effect: 'Allow',
Resource: { 'Fn::GetAtt': [expect.any(String), 'Arn'] },
},
],
},
});
// ...but hasResource is more powerful for complex assertions.
template.hasResource('AWS::Lambda::Function', {
Properties: {
Environment: {
Variables: {
TABLE_NAME: { 'Ref': expect.stringMatching(/^DynamoTable/) }
}
}
},
// This checks that the function explicitly depends on the table.
// CDK often adds these automatically, but you can verify it.
DependsOn: [ expect.arrayContaining([expect.stringMatching(/^DynamoTable/)]) ]
});
});
Best Practices: Don’t Over-Specify
The most common mistake is writing tests that are too specific and thus brittle. The library provides matchers like expect.any(String) or Match.absent() for a reason. Use them. If you don’t care about the exact value of a generated property (like a bucket’s autogenerated name), don’t test for it. Test for the properties that you explicitly set and that define your infrastructure’s correct behavior. Your goal isn’t to recreate the entire template in your test code; it’s to lock in the important business logic so you can refactor the rest with confidence. Now go forth and write tests that will save you from that 3 AM page. Your future self, trying to sleep, will thank you.