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

  1. has_resource_properties only checks the Properties block. To assert on the full resource including DeletionPolicy, DependsOn, or Metadata, use has_resource instead.
  2. Template.from_stack calls app.synth() implicitly - no need to call it manually.
  3. CDK assigns logical IDs based on the construct path. Changing a construct’s id argument changes its logical ID, which CloudFormation treats as a delete and recreate. Snapshot tests catch this; has_resource_properties tests do not.
  4. For TypeScript CDK projects, Jest has built-in snapshot support via toMatchSnapshot(). Python has no equivalent - the manual file approach above is the workaround.