This is Chapter 4 of the Infrastructure as Code with AWS CDK series. Chapter 3 built FileProcessorConstruct - the tests here cover that construct and the stack it lives in.
How CDK testing works
CDK unit tests don’t deploy anything. They synthesize a stack into a CloudFormation template and run assertions against that template. No AWS credentials needed, and the suite runs in CI without any special setup.
The aws_cdk.assertions module is already part of aws-cdk-lib - no extra install required.
Setup
cdk init adds pytest to requirements-dev.txt automatically. Install it if not already active:
pip install -r requirements-dev.txt
Create the test files:
my-cdk-app/
├── tests/
│ ├── __init__.py
│ ├── test_file_processor.py
│ └── test_stack.py
Testing the construct
Each test builds a minimal stack, instantiates the construct inside it, and asserts against the synthesized template:
# tests/test_file_processor.py
import pytest
from aws_cdk import App, Stack
from aws_cdk.assertions import Template, Match
from my_cdk_app.file_processor import FileProcessorConstruct
TEST_PROPS = dict(
bucket_name="test-bucket",
log_level="DEBUG",
lambda_timeout=30,
)
@pytest.fixture
def template():
app = App()
stack = Stack(app, "TestStack")
FileProcessorConstruct(stack, "TestConstruct", **TEST_PROPS)
return Template.from_stack(stack)
def test_bucket_created(template):
template.has_resource_properties("AWS::S3::Bucket", {
"BucketName": "test-bucket"
})
def test_lambda_runtime_and_timeout(template):
template.has_resource_properties("AWS::Lambda::Function", {
"Runtime": "python3.12",
"Timeout": 30,
"Environment": {
"Variables": {"LOG_LEVEL": "DEBUG"}
}
})
def test_lambda_has_read_access(template):
template.has_resource_properties("AWS::IAM::Policy", {
"PolicyDocument": {
"Statement": Match.array_with([
Match.object_like({
"Action": Match.array_with(["s3:GetObject*"]),
"Effect": "Allow"
})
])
}
})
has_resource_properties does a subset match - the resource only needs to contain the specified properties, not match them exactly. Match.array_with and Match.object_like apply the same subset logic to nested arrays and objects.
Testing the stack
Test MyStack the same way, using a config dict that matches what app.py passes:
# tests/test_stack.py
from aws_cdk import App
from aws_cdk.assertions import Template
from my_cdk_app.my_cdk_app_stack import MyStack
TEST_CONFIG = {
"bucket_name": "test-bucket",
"log_level": "INFO",
"lambda_timeout": 60,
}
def test_stack_resource_count():
app = App()
stack = MyStack(app, "TestStack", config=TEST_CONFIG)
template = Template.from_stack(stack)
template.resource_count_is("AWS::S3::Bucket", 1)
template.resource_count_is("AWS::Lambda::Function", 1)
Snapshot testing
Snapshot tests capture the full synthesized template and fail if anything changes. Write the template to a file on first run, then compare on subsequent runs:
import json
from pathlib import Path
from aws_cdk import App
from aws_cdk.assertions import Template
from my_cdk_app.my_cdk_app_stack import MyStack
SNAPSHOT_PATH = Path(__file__).parent / "snapshots" / "stack.json"
TEST_CONFIG = {
"bucket_name": "test-bucket",
"log_level": "INFO",
"lambda_timeout": 60,
}
def test_stack_snapshot():
app = App()
stack = MyStack(app, "TestStack", config=TEST_CONFIG)
template = Template.from_stack(stack).to_json()
if not SNAPSHOT_PATH.exists():
SNAPSHOT_PATH.parent.mkdir(exist_ok=True)
SNAPSHOT_PATH.write_text(json.dumps(template, indent=2))
return
expected = json.loads(SNAPSHOT_PATH.read_text())
assert template == expected
Commit the snapshot file. To update it after an intentional change, delete the file and run the test once to regenerate.
The downside: CDK version bumps routinely touch metadata in the template - aws:cdk:path, asset hashes, checksums. Any of these invalidates the snapshot even when nothing meaningful changed. They work well for catching unintended IAM or resource config changes, but generate noise as a general regression check.
Running tests
pytest
pytest -v # verbose output
pytest tests/test_file_processor.py # single file
Notes
has_resource_propertiesonly checks thePropertiesblock. To assert on the full resource includingDeletionPolicy,DependsOn, orMetadata, usehas_resourceinstead.Template.from_stackcallsapp.synth()implicitly - no need to call it manually.- CDK assigns logical IDs based on the construct path. Changing a construct’s
idargument changes its logical ID, which CloudFormation treats as a delete and recreate. Snapshot tests catch this;has_resource_propertiestests do not. - For TypeScript CDK projects, Jest has built-in snapshot support via
toMatchSnapshot(). Python has no equivalent - the manual file approach above is the workaround.