CDK Testing
Outlines best practices for testing AWS CDK applications. Testing is a critical aspect of CDK development to ensure that infrastructure is deployed correctly and behaves as expected. Following these guidelines will help create reliable, maintainable, and well-tested infrastructure code.
CDK
TypeScript
@cremich
Author
Submitted on April 11, 2025
# Testing ## Rules ### 1. Test-Driven Development Approach #### 1.1 Write Tests First - Follow a test-driven development (TDD) approach when possible - Write tests before implementing the infrastructure code - Use tests to specify and validate the expected behavior ```typescript // ✅ Example of writing a test first test("DynamoDbTable should have on-demand billing mode", () => { // ARRANGE const stack = new cdk.Stack(); // ACT new DatabaseTable(stack, "TestTable", { tableName: "test-table", partitionKey: "id", }); // ASSERT const template = Template.fromStack(stack); template.hasResourceProperties("AWS::DynamoDB::Table", { BillingMode: "PAY_PER_REQUEST", }); }); ``` #### 1.2 Test-Code-Refactor Cycle - Follow the TDD cycle: write a failing test, make it pass, then refactor - Keep tests simple and focused on one aspect of behavior - Refactor both the implementation and tests to improve quality ```typescript // Step 1: Write a failing test test("S3Bucket should have versioning enabled", () => { const stack = new cdk.Stack(); new SecureStorage(stack, "TestBucket", { bucketName: "test-bucket", enableVersioning: true, }); const template = Template.fromStack(stack); template.hasResourceProperties("AWS::S3::Bucket", { VersioningConfiguration: { Status: "Enabled", }, }); }); // Step 2: Implement the code to make the test pass // Step 3: Refactor for better design while keeping tests passing ``` ### 2. Unit Testing #### 2.1 Test Setup - Use Jest as the testing framework - Configure Jest in `package.json` and `jest.config.js` - Use the AWS CDK assertions module for testing CDK constructs ```typescript // package.json { "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }, "devDependencies": { "@types/jest": "^29.5.1", "jest": "^29.5.0", "ts-jest": "^29.1.0" } } // jest.config.js module.exports = { testEnvironment: 'node', roots: ['<rootDir>/test'], testMatch: ['**/*.test.ts'], transform: { '^.+\\.tsx?$': 'ts-jest' } }; ``` #### 2.2 Test Structure - Use the Arrange-Act-Assert pattern - Organize tests by construct or stack - Use descriptive test names that explain the expected behavior ```typescript // ✅ Well-structured test test("ApiGateway should have proper CORS configuration", () => { // ARRANGE const stack = new cdk.Stack(); const lambdaFunction = new lambda.Function(stack, "TestFunction", { runtime: lambda.Runtime.NODEJS_18_X, handler: "index.handler", code: lambda.Code.fromInline("exports.handler = async () => { return { statusCode: 200 }; }"), }); // ACT new ApiGatewayConstruct(stack, "TestApi", { lambdaFunction, allowOrigins: ["https://example.com"], }); // ASSERT const template = Template.fromStack(stack); template.hasResourceProperties("AWS::ApiGateway::RestApi", { Name: Match.anyValue(), }); template.hasResourceProperties("AWS::ApiGateway::Method", { HttpMethod: "GET", Integration: { Type: "AWS_PROXY", IntegrationHttpMethod: "POST", }, }); }); ``` #### 2.3 Fine-Grained Assertions - Use fine-grained assertions to test specific aspects of resources - Test resource properties, relationships, and configurations - Use `hasResourceProperties` for partial matching and `exactlyMatchTemplate` for exact matching ```typescript // ✅ Fine-grained assertions test("LambdaFunction should have proper configuration", () => { const stack = new cdk.Stack(); new ServerlessFunction(stack, "TestFunction", { functionName: "test-function", runtime: "nodejs18.x", memorySize: 512, timeout: 30, }); const template = Template.fromStack(stack); // Test specific properties template.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "test-function", Runtime: "nodejs18.x", MemorySize: 512, Timeout: 30, }); // Count resources template.resourceCountIs("AWS::Lambda::Function", 1); }); ``` #### 2.4 Snapshot Testing - Use snapshot testing to detect unintended changes in CloudFormation templates - Update snapshots only when changes are intentional - Don't rely solely on snapshots; combine with fine-grained assertions ```typescript // ✅ Snapshot testing test("ServerlessStack matches snapshot", () => { const app = new cdk.App(); const stack = new ServerlessStack(app, "TestStack"); // Create a snapshot of the CloudFormation template expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); }); ``` ### 3. Integration Testing #### 3.1 Integration Test Setup - Use the `integ-tests-alpha` module for integration testing - Create separate integration test files with the `.integ.ts` suffix - Define integration tests as CDK applications ```typescript // ✅ Integration test setup // api-gateway.integ.ts import * as cdk from "aws-cdk-lib"; import { IntegTest } from "@aws-cdk/integ-tests-alpha"; import { ApiGatewayStack } from "../lib/api-gateway-stack"; const app = new cdk.App(); const stack = new ApiGatewayStack(app, "IntegTest-ApiGateway", { apiName: "integ-test-api", }); new IntegTest(app, "ApiGatewayIntegTest", { testCases: [stack], // Optional: Configure CDK deployment cdkCommandOptions: { deploy: { args: { rollback: true, }, }, destroy: { args: { force: true, }, }, }, }); app.synth(); ``` #### 3.2 Testing Resource Creation - Test that resources are created successfully - Verify that resources have the expected properties - Test interactions between resources ```typescript // ✅ Testing resource creation // Define assertions in the integration test new IntegTest(app, "ApiGatewayIntegTest", { testCases: [stack], assertions: [ // Assert that the Lambda function is created { assertionStack: stack, resources: { "AWS::Lambda::Function": { properties: { Handler: "index.handler", Runtime: "nodejs18.x", }, }, }, }, // Assert that the API Gateway is created { assertionStack: stack, resources: { "AWS::ApiGateway::RestApi": { properties: { Name: Match.stringLikeRegexp("integ-test-api"), }, }, }, }, ], }); ``` #### 3.3 Running Integration Tests - Run integration tests in a controlled environment - Clean up resources after tests complete - Use the `cdk-integ` command to run integration tests ```bash # Run integration tests npx cdk-integ --app 'npx ts-node api-gateway.integ.ts' ApiGatewayIntegTest # Run with approval (to update snapshots) npx cdk-integ --app 'npx ts-node api-gateway.integ.ts' ApiGatewayIntegTest --approve ``` ### 4. Testing AWS Services #### 4.1 Lambda Testing - Test Lambda function configurations - Verify IAM role settings - Test environment variables and resource policies ```typescript // ✅ Testing Lambda resources test("LambdaFunction should have proper environment variables", () => { const stack = new cdk.Stack(); new ServerlessFunction(stack, "TestFunction", { functionName: "test-function", runtime: "nodejs18.x", environment: { TABLE_NAME: "test-table", API_ENDPOINT: "https://api.example.com", }, }); const template = Template.fromStack(stack); template.hasResourceProperties("AWS::Lambda::Function", { Environment: { Variables: { TABLE_NAME: "test-table", API_ENDPOINT: "https://api.example.com", }, }, }); }); ``` #### 4.2 DynamoDB Testing - Test DynamoDB table configurations - Verify key schema and attribute definitions - Test billing mode and capacity settings ```typescript // ✅ Testing DynamoDB resources test("DynamoDBTable should have proper key schema", () => { const stack = new cdk.Stack(); new DatabaseTable(stack, "TestTable", { tableName: "test-table", partitionKey: "id", sortKey: "timestamp", }); const template = Template.fromStack(stack); template.hasResourceProperties("AWS::DynamoDB::Table", { KeySchema: [ { AttributeName: "id", KeyType: "HASH", }, { AttributeName: "timestamp", KeyType: "RANGE", }, ], AttributeDefinitions: [ { AttributeName: "id", AttributeType: "S", }, { AttributeName: "timestamp", AttributeType: "S", }, ], }); }); ``` #### 4.3 API Gateway Testing - Test API Gateway configurations - Verify methods and integrations - Test security settings and CORS configurations ```typescript // ✅ Testing API Gateway resources test("ApiGateway should have proper CORS configuration", () => { const stack = new cdk.Stack(); new ApiGatewayConstruct(stack, "TestApi", { lambdaFunction: new lambda.Function(stack, "TestFunction", { runtime: lambda.Runtime.NODEJS_18_X, handler: "index.handler", code: lambda.Code.fromInline("exports.handler = () => {}"), }), allowOrigins: ["https://example.com"], }); const template = Template.fromStack(stack); template.hasResourceProperties("AWS::ApiGateway::RestApi", { Name: Match.anyValue(), }); template.hasResourceProperties("AWS::ApiGateway::Method", { HttpMethod: "OPTIONS", Integration: { IntegrationResponses: [ { ResponseParameters: { "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", "method.response.header.Access-Control-Allow-Methods": "'GET,POST,PUT,DELETE,OPTIONS'", "method.response.header.Access-Control-Allow-Origin": "'https://example.com'", }, }, ], }, }); }); ``` ### 5. Test Coverage #### 5.1 Coverage Goals - Aim for high test coverage (at least 80%) - Focus on testing critical infrastructure components - Ensure all resource properties are tested ```typescript // package.json { "scripts": { "test:coverage": "jest --coverage" }, "jest": { "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 } } } } ``` #### 5.2 Coverage Reports - Generate coverage reports to identify untested code - Review coverage reports regularly - Address coverage gaps in critical areas ```bash # Generate coverage report npm run test:coverage ``` #### 5.3 Continuous Integration - Run tests automatically in CI/CD pipelines - Fail builds if tests fail or coverage drops below thresholds - Archive test results and coverage reports ```yaml # .github/workflows/test.yml name: Test on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: "18" - name: Install dependencies run: npm ci - name: Run tests with coverage run: npm run test:coverage - name: Archive test results uses: actions/upload-artifact@v3 with: name: coverage-report path: coverage/ ``` ## Examples ### Unit Test Example for Lambda Construct ```typescript // test/lambda-function.test.ts import * as cdk from "aws-cdk-lib"; import { Template } from "aws-cdk-lib/assertions"; import { ServerlessFunction } from "../lib/lambda/serverless-function"; describe("ServerlessFunction", () => { test("creates function with default configuration", () => { // ARRANGE const stack = new cdk.Stack(); // ACT new ServerlessFunction(stack, "TestFunction", { functionName: "test-function", handler: "index.handler", codePath: "lambda", }); // ASSERT const template = Template.fromStack(stack); template.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "test-function", Handler: "index.handler", Runtime: "nodejs18.x", // Default runtime MemorySize: 256, // Default memory size Timeout: 30, // Default timeout }); }); test("creates function with custom configuration", () => { // ARRANGE const stack = new cdk.Stack(); // ACT new ServerlessFunction(stack, "TestFunction", { functionName: "test-function", handler: "app.handler", codePath: "lambda", runtime: "python3.9", memorySize: 512, timeout: 60, environment: { STAGE: "test", }, }); // ASSERT const template = Template.fromStack(stack); template.hasResourceProperties("AWS::Lambda::Function", { FunctionName: "test-function", Handler: "app.handler", Runtime: "python3.9", MemorySize: 512, Timeout: 60, Environment: { Variables: { STAGE: "test", }, }, }); }); test("creates function with proper IAM role", () => { // ARRANGE const stack = new cdk.Stack(); // ACT new ServerlessFunction(stack, "TestFunction", { functionName: "test-function", handler: "index.handler", codePath: "lambda", permissions: ["dynamodb:GetItem", "dynamodb:PutItem"], }); // ASSERT const template = Template.fromStack(stack); template.hasResourceProperties("AWS::IAM::Role", { AssumeRolePolicyDocument: { Statement: [ { Action: "sts:AssumeRole", Effect: "Allow", Principal: { Service: "lambda.amazonaws.com", }, }, ], }, ManagedPolicyArns: [ { "Fn::Join": [ "", ["arn:", { Ref: "AWS::Partition" }, ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"], ], }, ], }); template.hasResourceProperties("AWS::IAM::Policy", { PolicyDocument: { Statement: [ { Action: ["dynamodb:GetItem", "dynamodb:PutItem"], Effect: "Allow", Resource: Match.anyValue(), }, ], }, }); }); }); ``` ### Integration Test Example for Serverless Stack ```typescript // integ/serverless-api.integ.ts import * as cdk from "aws-cdk-lib"; import { IntegTest } from "@aws-cdk/integ-tests-alpha"; import { ServerlessStack } from "../lib/serverless-stack"; const app = new cdk.App(); // Create the stack with integration test configuration const stack = new ServerlessStack(app, "IntegTest-ServerlessApi", { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, apiName: "integ-test-api", tableName: "integ-test-table", }); // Define the integration test const integ = new IntegTest(app, "ServerlessApiIntegTest", { testCases: [stack], diffAssets: true, stackUpdateWorkflow: true, }); // Add assertions to verify the deployed resources const lambdaFunction = integ.assertions.awsApiCall("Lambda", "getFunction", { FunctionName: stack.functionName, }); lambdaFunction.expect("Configuration.Runtime").toEqual("nodejs18.x"); lambdaFunction.expect("Configuration.Handler").toEqual("index.handler"); // Add assertions for DynamoDB table const dynamoTable = integ.assertions.awsApiCall("DynamoDB", "describeTable", { TableName: stack.tableName, }); dynamoTable.expect("Table.TableName").toEqual(stack.tableName); dynamoTable.expect("Table.BillingModeSummary.BillingMode").toEqual("PAY_PER_REQUEST"); // Add assertions for API Gateway const apiGateway = integ.assertions.awsApiCall("ApiGateway", "getRestApi", { restApiId: stack.apiId, }); apiGateway.expect("name").toEqual("integ-test-api"); app.synth(); ``` ### Snapshot Test Example ```typescript // test/serverless-stack.test.ts import * as cdk from "aws-cdk-lib"; import { Template } from "aws-cdk-lib/assertions"; import { ServerlessStack } from "../lib/serverless-stack"; describe("ServerlessStack", () => { test("synthesizes as expected", () => { // ARRANGE const app = new cdk.App(); // ACT const stack = new ServerlessStack(app, "TestStack", { apiName: "test-api", tableName: "test-table", }); // ASSERT expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); }); test("creates all required resources", () => { // ARRANGE const app = new cdk.App(); // ACT const stack = new ServerlessStack(app, "TestStack", { apiName: "test-api", tableName: "test-table", }); // ASSERT const template = Template.fromStack(stack); template.resourceCountIs("AWS::Lambda::Function", 1); template.resourceCountIs("AWS::DynamoDB::Table", 1); template.resourceCountIs("AWS::ApiGateway::RestApi", 1); template.resourceCountIs("AWS::ApiGateway::Method", 5); // GET, POST, PUT, DELETE, OPTIONS template.resourceCountIs("AWS::ApiGateway::Deployment", 1); }); }); ``` ## Anti-patterns ### ❌ No Tests ```typescript // ❌ Bad: Deploying code without tests export class ServerlessStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // Creating resources without tests new dynamodb.Table(this, "Table", { // Configuration }); new lambda.Function(this, "Function", { // Configuration }); // More resources without tests } } // No tests written for this stack ``` **Why it's bad**: Deploying infrastructure without tests can lead to unexpected behavior, security issues, and deployment failures. Tests help catch issues early and provide confidence in the infrastructure code. ### ❌ Testing Implementation Details ```typescript // ❌ Bad: Testing implementation details test("ServerlessFunction uses internal helper method", () => { const func = new ServerlessFunction(new cdk.Stack(), "TestFunction", { functionName: "test-function", handler: "index.handler", codePath: "lambda", }); // Accessing private method const privateMethod = (func as any).createRole(); // Testing implementation details expect(privateMethod).toBeDefined(); expect(privateMethod.assumeRolePolicy).toBeDefined(); }); ``` **Why it's bad**: Testing implementation details makes tests brittle and prone to breaking when refactoring. Tests should focus on the public API and the resulting CloudFormation template, not internal implementation details. ### ❌ Hardcoded Test Values ```typescript // ❌ Bad: Hardcoded test values test("DynamoDBTable creates table", () => { const stack = new cdk.Stack(); new DatabaseTable(stack, "TestTable", { tableName: "test-table", partitionKey: "id", }); // Hardcoded JSON expectation expect(Template.fromStack(stack).toJSON()).toEqual({ Resources: { TestTable: { Type: "AWS::DynamoDB::Table", Properties: { TableName: "test-table", BillingMode: "PAY_PER_REQUEST", KeySchema: [ { AttributeName: "id", KeyType: "HASH", }, ], AttributeDefinitions: [ { AttributeName: "id", AttributeType: "S", }, ], }, }, }, }); }); ``` **Why it's bad**: Hardcoding exact JSON expectations makes tests brittle. CloudFormation templates can change due to CDK updates or resource property changes. Use fine-grained assertions or snapshots instead. ### ❌ Missing Integration Tests ```typescript // ❌ Bad: Only unit tests, no integration tests // Only unit tests for individual constructs test("LambdaFunction creates function", () => { // Unit test for function }); test("DynamoDBTable creates table", () => { // Unit test for table }); test("ApiGateway creates API", () => { // Unit test for API }); // No integration tests to verify how these components work together ``` **Why it's bad**: Unit tests alone don't verify that components work together correctly or that the infrastructure can be deployed successfully. Integration tests are needed to test the complete system. ### ❌ Overreliance on Snapshots ```typescript // ❌ Bad: Overreliance on snapshots describe("ServerlessStack", () => { test("snapshot test", () => { const app = new cdk.App(); const stack = new ServerlessStack(app, "TestStack"); // Only snapshot test, no specific assertions expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); }); // No other tests with specific assertions }); ``` **Why it's bad**: Relying solely on snapshots makes it hard to understand what's being tested and why. Snapshots can hide issues and make it difficult to identify the root cause of failures. Use snapshots in combination with specific assertions. ## References - [AWS CDK Testing Documentation](https://docs.aws.amazon.com/cdk/v2/guide/testing.html) - [AWS CDK Assertions Module](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html) - [Jest Documentation](https://jestjs.io/docs/getting-started) - [AWS CDK Integration Tests](https://docs.aws.amazon.com/cdk/api/v2/docs/integ-tests-alpha-readme.html)