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)