Guide · AWS CDK · Testing
MCP Server CDK Testing — Template Assertions, Capture, Snapshot Tests
CDK's assertions library (aws-cdk-lib/assertions) lets you verify that a stack synthesizes the CloudFormation resources you expect — without deploying to AWS. The testing model: synthesize the stack into a Template object, then make assertions against that template's JSON. Three assertion styles cover everything: fine-grained assertions (template.hasResourceProperties, template.resourceCountIs) verify specific resources exist with the right properties; snapshot tests (template.toJSON()) lock the entire synthesized CloudFormation template as a Jest snapshot — a one-line catch-all that fails when anything changes; Capture matchers extract dynamic values (like CDK-generated logical IDs or token-resolved ARNs) from the template so you can assert on them structurally. The critical limitation: CDK tests verify that your code generates the right template, not that the template actually works in AWS — always combine with real integration tests for production infrastructure.
TL;DR
Use Template.fromStack(stack) to synthesize. Call template.hasResourceProperties('AWS::ECS::TaskDefinition', { ... }) for specific assertions. Use Match.objectLike for partial matching and Match.arrayWith for arrays that may contain additional items. Use Capture to extract CDK-generated values. Use template.toJSON() as a Jest snapshot for full regression coverage.
Setup and basic assertions
Import Template and Match from aws-cdk-lib/assertions. Create a test CDK app, instantiate your stack under test, then call Template.fromStack(stack) to synthesize without an AWS connection.
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { McpServerStack } from '../lib/mcp-server-stack';
describe('McpServerStack', () => {
let template: Template;
beforeEach(() => {
const app = new cdk.App();
const stack = new McpServerStack(app, 'TestStack', {
env: { account: '123456789012', region: 'us-east-1' },
});
template = Template.fromStack(stack);
});
test('creates ECS task definition with correct CPU and memory', () => {
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
Cpu: '512',
Memory: '1024',
NetworkMode: 'awsvpc',
RequiresCompatibilities: ['FARGATE'],
});
});
test('creates exactly one ECS service', () => {
template.resourceCountIs('AWS::ECS::Service', 1);
});
test('ECS service has at least 1 desired count', () => {
template.hasResourceProperties('AWS::ECS::Service', {
DesiredCount: Match.not(0),
});
});
});
hasResourceProperties performs a partial match by default — the resource must have all the specified properties but may have additional properties. This is intentional: CDK adds many derived properties to resources and you rarely want to assert on all of them.
Match matchers for partial and structural assertions
The Match class provides matchers that can be nested anywhere in an assertion's property object:
| Matcher | Behavior |
|---|---|
Match.exact(value) | Exact deep equality — no extra keys allowed |
Match.objectLike(subset) | Object must contain all keys in subset (default for hasResourceProperties) |
Match.objectEquals(obj) | Alias for Match.exact |
Match.arrayWith(items) | Array must contain all items in items (order-independent, may have extras) |
Match.arrayEquals(items) | Array must equal items exactly (length and order) |
Match.stringLikeRegexp(pattern) | String must match the regex pattern |
Match.anyValue() | Matches any non-undefined value (useful as a placeholder) |
Match.absent() | Key must not be present in the object |
Match.not(pattern) | Negates any matcher |
test('task definition has MCP_PORT env var', () => {
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
ContainerDefinitions: Match.arrayWith([
Match.objectLike({
Name: 'mcp-server',
Environment: Match.arrayWith([
{ Name: 'MCP_PORT', Value: '3000' },
]),
// Port mappings must include port 3000
PortMappings: Match.arrayWith([
Match.objectLike({ ContainerPort: 3000, Protocol: 'tcp' }),
]),
}),
]),
});
});
test('task role has DynamoDB permissions', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Action: Match.arrayWith(['dynamodb:GetItem', 'dynamodb:PutItem']),
Effect: 'Allow',
}),
]),
},
});
});
test('no S3 bucket has public read access', () => {
// Ensure no bucket has public ACL — catches accidental misconfigurations
template.allResourcesProperties('AWS::S3::Bucket', {
AccessControl: Match.absent(), // absence of AccessControl means private
});
});
Capture — extracting dynamic values
Capture acts as a "capture group" inside a matcher — it matches any value and stores what it matched so you can assert on it after the fact. This is essential for asserting on CDK-generated logical IDs, token-resolved ARNs, or any value that CDK generates dynamically.
import { Capture } from 'aws-cdk-lib/assertions';
test('ECS service references the correct task definition', () => {
// Capture the task definition ARN from the ECS service resource
const taskDefCapture = new Capture();
template.hasResourceProperties('AWS::ECS::Service', {
TaskDefinition: taskDefCapture,
});
// taskDefCapture.asString() is now the CloudFormation ref/ARN value
// We can assert it references our expected task definition
expect(taskDefCapture.asString()).toMatch(/McpServerTask/);
});
test('IAM role trust policy allows ECS tasks', () => {
const principalCapture = new Capture();
template.hasResourceProperties('AWS::IAM::Role', {
AssumeRolePolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Principal: { Service: principalCapture },
}),
]),
},
});
// ecs-tasks.amazonaws.com could appear as a string or in an array
const principal = principalCapture.asString();
expect(principal).toContain('ecs-tasks.amazonaws.com');
});
test('log group retention matches expected value', () => {
const retentionCapture = new Capture();
template.hasResourceProperties('AWS::Logs::LogGroup', {
RetentionInDays: retentionCapture,
});
// Verify retention is at least 30 days
expect(retentionCapture.asNumber()).toBeGreaterThanOrEqual(30);
});
Capture stores the first match when used with hasResourceProperties and multiple resources match. Use findResources to iterate over all matching resources when you need to assert across all instances.
Snapshot testing
Snapshot testing with template.toJSON() captures the entire synthesized CloudFormation template and compares it against a stored snapshot file. This is a cheap regression guard — any change to the generated CloudFormation (new resource, changed property, different logical ID) will fail the snapshot test, forcing a deliberate snapshot update.
test('snapshot matches expected template', () => {
expect(template.toJSON()).toMatchSnapshot();
});
Update snapshots intentionally when you've made a deliberate change: npx jest --updateSnapshot. Never blindly accept all snapshot changes — review the diff to confirm only expected resources changed. Snapshot tests fail often on CDK upgrades that change logical ID generation, IAM policy formatting, or default property values.
For large stacks where full snapshots are noisy, snapshot a subset of resources:
test('ECS resources match snapshot', () => {
// Only snapshot ECS-related resources, not the entire template
const ecsResources = template.findResources('AWS::ECS::TaskDefinition');
expect(ecsResources).toMatchSnapshot();
const ecsServices = template.findResources('AWS::ECS::Service');
expect(ecsServices).toMatchSnapshot();
});
Testing conditional logic and stack parameters
When a stack has conditional resources (different behavior based on props), test both branches by instantiating the stack twice with different props.
describe('when enablePrivateLink is true', () => {
let template: Template;
beforeEach(() => {
const app = new cdk.App();
const stack = new McpServerStack(app, 'TestStack', {
enablePrivateLink: true,
env: { account: '123456789012', region: 'us-east-1' },
});
template = Template.fromStack(stack);
});
test('creates VPC endpoint', () => {
template.resourceCountIs('AWS::EC2::VPCEndpoint', 1);
template.hasResourceProperties('AWS::EC2::VPCEndpoint', {
VpcEndpointType: 'Interface',
});
});
});
describe('when enablePrivateLink is false (default)', () => {
let template: Template;
beforeEach(() => {
const app = new cdk.App();
const stack = new McpServerStack(app, 'TestStack', {
enablePrivateLink: false,
env: { account: '123456789012', region: 'us-east-1' },
});
template = Template.fromStack(stack);
});
test('does not create VPC endpoint', () => {
template.resourceCountIs('AWS::EC2::VPCEndpoint', 0);
});
});
Asserting on CloudFormation outputs and parameters
Template provides specific methods for Outputs and Parameters — use hasOutput and findOutputs to assert on CfnOutput values your stack exports.
test('exports MCP service URL', () => {
template.hasOutput('McpServiceUrl', {
Description: Match.stringLikeRegexp(/MCP server.*URL/i),
// Value is a CloudFormation intrinsic; use Match.objectLike to avoid exact matching
Value: Match.anyValue(),
Export: {
Name: Match.stringLikeRegexp(/McpServiceUrl/),
},
});
});
test('exports load balancer DNS name', () => {
const outputs = template.findOutputs('*', {
Description: Match.stringLikeRegexp(/load balancer/i),
});
expect(Object.keys(outputs)).toHaveLength(1);
});
Common testing failures
| Symptom | Cause | Fix |
|---|---|---|
hasResourceProperties throws "No resource with type X found" | Resource wasn't created — prop or conditional blocked it, or wrong resource type string | Use template.findResources('AWS::...') to list what's actually in the template |
| Assertion fails on CDK version bump | CDK upgrade changed logical ID generation, default property values, or IAM policy formatting | Run jest --updateSnapshot for snapshots; for fine-grained assertions, use Match.objectLike not Match.exact to tolerate new default properties |
Capture.asString() returns a CDK token (${Token[...]}) instead of a real value | CDK couldn't resolve the token at synth time — it's still a lazy reference | Use Match.anyValue() and check the token pattern, or move the assertion to an integration test where real values are available |
| Snapshot update on every run | Stack uses Date.now() or random values in resource names | Remove non-deterministic values from CDK constructs — resource names should be deterministic given the same CDK app inputs |
template.resourceCountIs fails with wrong count | CDK creates helper resources (IAM roles, Lambda functions for custom resources, log groups) automatically | Use template.findResources to see all resources of that type and understand why the count differs |