CDKTF Testing Guide: Unit and Integration Tests for CDK for Terraform

CDKTF Testing Guide: Unit and Integration Tests for CDK for Terraform

CDK for Terraform (CDKTF) lets you define infrastructure in TypeScript, Python, Go, or Java and synthesize it to Terraform JSON. This brings software testing practices to infrastructure: unit test your constructs, snapshot test synthesized output, and run integration tests against real cloud resources.

This guide covers CDKTF testing from construct-level unit tests through full deployment validation.

Why Testing CDKTF Is Different

CDKTF has a two-phase execution model:

  1. Synthesis: your code runs and produces Terraform JSON in cdktf.out/
  2. Deployment: Terraform applies the synthesized JSON

You can test at both phases:

  • Unit tests: test the construct logic and assert synthesized output without deploying
  • Integration tests: synthesize, deploy, validate, and destroy real resources

Unit tests are cheap (milliseconds, no AWS credentials). Integration tests cost real money and time. Most bugs are caught in unit tests.

Project Setup

# Install dependencies
npm install --save-dev @cdktf/provider-aws cdktf constructs
npm install --save-dev jest @types/jest ts-jest

# For CDKTF testing matchers
npm install --save-dev cdktf
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};

Unit Testing with Testing.app()

CDKTF provides Testing utilities specifically for unit testing constructs:

// src/constructs/vpc.ts
import { Construct } from 'constructs';
import { Vpc } from '@cdktf/provider-aws/lib/vpc';
import { Subnet } from '@cdktf/provider-aws/lib/subnet';
import { InternetGateway } from '@cdktf/provider-aws/lib/internet-gateway';

export interface VpcConstructConfig {
  name: string;
  cidr: string;
  azs: string[];
  environment: string;
}

export class VpcConstruct extends Construct {
  public readonly vpc: Vpc;
  public readonly privateSubnets: Subnet[];
  public readonly publicSubnets: Subnet[];

  constructor(scope: Construct, id: string, config: VpcConstructConfig) {
    super(scope, id);

    this.vpc = new Vpc(this, 'vpc', {
      cidrBlock: config.cidr,
      enableDnsHostnames: true,
      enableDnsSupport: true,
      tags: {
        Name: config.name,
        Environment: config.environment,
      },
    });

    const igw = new InternetGateway(this, 'igw', {
      vpcId: this.vpc.id,
      tags: { Name: `${config.name}-igw` },
    });

    this.privateSubnets = config.azs.map((az, i) =>
      new Subnet(this, `private-${i}`, {
        vpcId: this.vpc.id,
        cidrBlock: `${config.cidr.replace('.0/16', '')}.${i}.0/24`,
        availabilityZone: az,
        mapPublicIpOnLaunch: false,
        tags: {
          Name: `${config.name}-private-${az}`,
          Tier: 'private',
        },
      })
    );

    this.publicSubnets = config.azs.map((az, i) =>
      new Subnet(this, `public-${i}`, {
        vpcId: this.vpc.id,
        cidrBlock: `${config.cidr.replace('.0/16', '')}.10${i}.0/24`,
        availabilityZone: az,
        mapPublicIpOnLaunch: true,
        tags: {
          Name: `${config.name}-public-${az}`,
          Tier: 'public',
        },
      })
    );
  }
}
// src/__tests__/vpc.test.ts
import { Testing } from 'cdktf';
import { Vpc } from '@cdktf/provider-aws/lib/vpc';
import { Subnet } from '@cdktf/provider-aws/lib/subnet';
import { VpcConstruct } from '../constructs/vpc';
import { AwsProvider } from '@cdktf/provider-aws/lib/provider';
import { TerraformStack } from 'cdktf';

describe('VpcConstruct', () => {
  const config = {
    name: 'test-vpc',
    cidr: '10.0.0.0/16',
    azs: ['us-east-1a', 'us-east-1b', 'us-east-1c'],
    environment: 'test',
  };

  test('creates VPC with correct CIDR', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', config);

    expect(Testing.synth(stack)).toHaveResourceWithProperties(
      Vpc, {
        cidr_block: '10.0.0.0/16',
        enable_dns_hostnames: true,
        enable_dns_support: true,
      }
    );
  });

  test('creates correct number of private subnets', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', config);

    const synthesized = Testing.synth(stack);
    const subnets = JSON.parse(synthesized).resource?.aws_subnet;

    const privateSubnets = Object.values(subnets || {}).filter(
      (s: any) => s.map_public_ip_on_launch === false
    );

    expect(privateSubnets).toHaveLength(3);
  });

  test('private subnets do not map public IPs', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', config);

    const synthesized = JSON.parse(Testing.synth(stack));
    const subnets = Object.values(synthesized.resource?.aws_subnet || {});

    subnets
      .filter((s: any) => s.tags?.Tier === 'private')
      .forEach((s: any) => {
        expect(s.map_public_ip_on_launch).toBe(false);
      });
  });

  test('tags include environment', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', config);

    expect(Testing.synth(stack)).toHaveResourceWithProperties(Vpc, {
      tags: expect.objectContaining({
        Environment: 'test',
        Name: 'test-vpc',
      }),
    });
  });

  test('is valid Terraform', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', config);

    // Validates that the synthesized output is structurally valid Terraform JSON
    expect(Testing.fullSynth(stack)).toBeValidTerraform();
  });
});

Snapshot Testing

Snapshot tests catch unintended changes to synthesized Terraform:

// src/__tests__/vpc.snapshot.test.ts
import { Testing } from 'cdktf';
import { TerraformStack } from 'cdktf';
import { AwsProvider } from '@cdktf/provider-aws/lib/provider';
import { VpcConstruct } from '../constructs/vpc';

describe('VpcConstruct Snapshots', () => {
  test('VPC snapshot matches', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');

    new AwsProvider(stack, 'aws', { region: 'us-east-1' });
    new VpcConstruct(stack, 'vpc', {
      name: 'test-vpc',
      cidr: '10.0.0.0/16',
      azs: ['us-east-1a', 'us-east-1b'],
      environment: 'test',
    });

    expect(Testing.synth(stack)).toMatchSnapshot();
  });
});

First run creates __snapshots__/vpc.snapshot.test.ts.snap. Subsequent runs fail if the synthesized output changes unexpectedly.

When you intentionally change a construct, update snapshots:

npx jest --updateSnapshot

Commit snapshot files to version control. A snapshot diff in a PR shows exactly what Terraform resources will change.

Testing Security Group Rules

Security groups are particularly worth unit testing — a misconfigured rule is a security incident:

// src/constructs/security-groups.ts
export class WebSecurityGroup extends Construct {
  public readonly sg: SecurityGroup;

  constructor(scope: Construct, id: string, vpcId: string) {
    super(scope, id);

    this.sg = new SecurityGroup(this, 'web-sg', {
      vpcId,
      name: 'web-sg',
      description: 'Security group for web tier',
      ingress: [
        { fromPort: 80, toPort: 80, protocol: 'tcp', cidrBlocks: ['0.0.0.0/0'] },
        { fromPort: 443, toPort: 443, protocol: 'tcp', cidrBlocks: ['0.0.0.0/0'] },
      ],
      egress: [
        { fromPort: 0, toPort: 0, protocol: '-1', cidrBlocks: ['0.0.0.0/0'] },
      ],
    });
  }
}
// src/__tests__/security-groups.test.ts
describe('WebSecurityGroup', () => {
  test('allows HTTP and HTTPS inbound', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');
    new AwsProvider(stack, 'aws', { region: 'us-east-1' });

    new WebSecurityGroup(stack, 'sg', 'vpc-123');

    const synth = JSON.parse(Testing.synth(stack));
    const sg = Object.values(synth.resource.aws_security_group)[0] as any;

    const allowedPorts = sg.ingress.map((r: any) => r.from_port);
    expect(allowedPorts).toContain(80);
    expect(allowedPorts).toContain(443);
  });

  test('does NOT allow SSH from the internet', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');
    new AwsProvider(stack, 'aws', { region: 'us-east-1' });

    new WebSecurityGroup(stack, 'sg', 'vpc-123');

    const synth = JSON.parse(Testing.synth(stack));
    const sg = Object.values(synth.resource.aws_security_group)[0] as any;

    const sshRules = sg.ingress.filter(
      (r: any) => r.from_port === 22 && r.cidr_blocks?.includes('0.0.0.0/0')
    );

    expect(sshRules).toHaveLength(0);
  });

  test('does NOT have 0.0.0.0/0 for database port', () => {
    const app = Testing.app();
    const stack = new TerraformStack(app, 'test');
    new AwsProvider(stack, 'aws', { region: 'us-east-1' });

    new WebSecurityGroup(stack, 'sg', 'vpc-123');

    const synth = JSON.parse(Testing.synth(stack));
    const sg = Object.values(synth.resource.aws_security_group)[0] as any;

    const dbRules = sg.ingress.filter(
      (r: any) => [5432, 3306, 1433].includes(r.from_port) &&
                  r.cidr_blocks?.includes('0.0.0.0/0')
    );

    expect(dbRules).toHaveLength(0);
  });
});

Testing Stack Composition

Test that multiple constructs wire together correctly:

// src/stacks/web-stack.ts
export class WebStack extends TerraformStack {
  constructor(app: App, id: string, env: string) {
    super(app, id);

    new AwsProvider(this, 'aws', { region: 'us-east-1' });

    const vpc = new VpcConstruct(this, 'vpc', {
      name: `${env}-vpc`,
      cidr: env === 'prod' ? '10.2.0.0/16' : '10.0.0.0/16',
      azs: ['us-east-1a', 'us-east-1b', 'us-east-1c'],
      environment: env,
    });

    const sg = new WebSecurityGroup(this, 'sg', vpc.vpc.id);

    new Instance(this, 'web', {
      ami: 'ami-0c02fb55956c7d316',
      instanceType: 't3.micro',
      subnetId: vpc.publicSubnets[0].id,
      vpcSecurityGroupIds: [sg.sg.id],
      tags: { Environment: env, Role: 'web' },
    });
  }
}
// src/__tests__/web-stack.test.ts
describe('WebStack', () => {
  test('prod uses different CIDR than dev', () => {
    const devApp = Testing.app();
    const devStack = new WebStack(devApp as any, 'dev-stack', 'dev');

    const prodApp = Testing.app();
    const prodStack = new WebStack(prodApp as any, 'prod-stack', 'prod');

    const devSynth = JSON.parse(Testing.synth(devStack));
    const prodSynth = JSON.parse(Testing.synth(prodStack));

    const devVpc = Object.values(devSynth.resource.aws_vpc)[0] as any;
    const prodVpc = Object.values(prodSynth.resource.aws_vpc)[0] as any;

    expect(devVpc.cidr_block).toBe('10.0.0.0/16');
    expect(prodVpc.cidr_block).toBe('10.2.0.0/16');
  });

  test('instance is in public subnet', () => {
    const app = Testing.app();
    const stack = new WebStack(app as any, 'test', 'dev');

    const synth = JSON.parse(Testing.synth(stack));

    // EC2 instance should reference a subnet with public IP mapping
    const instance = Object.values(synth.resource.aws_instance)[0] as any;
    expect(instance.subnet_id).toBeDefined();
  });

  test('full synthesis is valid Terraform', () => {
    const app = Testing.app();
    const stack = new WebStack(app as any, 'test', 'dev');

    expect(Testing.fullSynth(stack)).toBeValidTerraform();
  });
});

Integration Testing with Terratest

For full deploy-validate-destroy cycles:

// test/integration/cdktf_test.go
package test

import (
    "fmt"
    "os"
    "os/exec"
    "testing"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWebStackDeployment(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping integration test")
    }

    // Synthesize CDKTF
    synthCmd := exec.Command("npx", "cdktf", "synth", "--stack", "test-stack")
    synthCmd.Dir = "../.."
    synthCmd.Env = append(os.Environ(), "ENVIRONMENT=test")
    out, err := synthCmd.CombinedOutput()
    require.NoError(t, err, "CDKTF synth failed: %s", out)

    // The synthesized Terraform is in cdktf.out/stacks/test-stack/
    terraformDir := "../../cdktf.out/stacks/test-stack"

    opts := &terraform.Options{
        TerraformDir: terraformDir,
        Vars: map[string]interface{}{
            "environment": "test",
        },
    }

    defer terraform.Destroy(t, opts)

    terraform.InitAndApply(t, opts)

    // Validate outputs
    instanceID := terraform.Output(t, opts, "instance_id")
    vpcID := terraform.Output(t, opts, "vpc_id")

    // Assert VPC exists with correct CIDR
    vpc := aws.GetVpcById(t, vpcID, "us-east-1")
    assert.Equal(t, "10.0.0.0/16", vpc.CidrBlock)

    // Assert instance is running
    instance := aws.GetEc2InstanceIdsByFilters(t, "us-east-1",
        map[string][]string{
            "instance-id": {instanceID},
        },
    )
    assert.Contains(t, instance, instanceID)
}

CI Pipeline

# .github/workflows/cdktf-tests.yml
name: CDKTF Tests

on:
  pull_request:
    paths:
      - 'src/**'
      - 'test/**'

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npx jest --coverage --forceExit

      - name: Upload coverage
        uses: codecov/codecov-action@v4

  snapshot-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Check snapshots are up to date
        run: |
          npx jest --ci  # --ci fails on snapshot mismatch instead of updating
        
      - name: Synth and validate
        run: |
          npx cdktf synth
          # Check synthesized output for required resources
          jq '.resource | keys' cdktf.out/stacks/*/cdk.tf.json

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    if: github.ref == 'refs/heads/main'
    environment: test-account
    steps:
      - uses: actions/checkout@v4

      - name: Setup tools
        run: |
          npm ci
          go mod download

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::999999999999:role/cdktf-test
          aws-region: us-east-1

      - name: Run integration tests
        run: go test ./test/integration/... -v -timeout 60m -tags integration

Python CDKTF Testing

If you're using Python CDKTF:

# tests/test_vpc_construct.py
import json
import pytest
from cdktf import Testing, TerraformStack, App
from cdktf_cdktf_provider_aws.provider import AwsProvider
from constructs import VpcConstruct  # your construct

class TestVpcConstruct:
    def setup_method(self):
        self.app = Testing.app()
        self.stack = TerraformStack(self.app, "test")
        AwsProvider(self.stack, "aws", region="us-east-1")

    def test_vpc_has_correct_cidr(self):
        VpcConstruct(self.stack, "vpc",
            name="test",
            cidr="10.0.0.0/16",
            azs=["us-east-1a"],
            environment="test"
        )
        synth = json.loads(Testing.synth(self.stack))
        vpcs = synth["resource"]["aws_vpc"]
        assert any(v["cidr_block"] == "10.0.0.0/16" for v in vpcs.values())

    def test_full_synth_is_valid(self):
        VpcConstruct(self.stack, "vpc",
            name="test",
            cidr="10.0.0.0/16",
            azs=["us-east-1a"],
            environment="test"
        )
        assert Testing.full_synth(self.stack)  # validates Terraform JSON

Common Pitfalls

Token references in unit tests: CDKTF uses token strings (${aws_vpc.test-vpc.id}) in synthesized output when resources reference each other. Don't assert exact string values for token-resolved fields — assert structure or use toContain:

// Wrong:
expect(subnet.vpc_id).toBe('vpc-12345');

// Right:
expect(subnet.vpc_id).toMatch(/\$\{aws_vpc\./);
// or use the construct directly:
expect(vpc.vpc.id).toBeDefined();

Snapshot flakiness: CDKTF generates unique IDs based on construct scope. Changes to the construct tree (adding/removing constructs before the one being tested) can change IDs and break snapshots without any real change. Scope your snapshots to minimal stacks.

fullSynth vs synth: Testing.synth() produces JSON but doesn't validate it against the Terraform schema. Testing.fullSynth() runs terraform validate — use it at least once per stack, but it's slower (requires Terraform binary).

Summary

CDKTF testing unlocks software engineering practices for infrastructure: unit tests that run in milliseconds, snapshot tests that document intended state, and security-focused assertions that fail fast when a port opens to the internet. The testing pyramid applies: many unit tests, few integration tests, snapshot tests for change detection.

Start with Testing.app() + toHaveResourceWithProperties() for your core constructs, add snapshot tests for stack composition, and graduate to Terratest integration tests for validation of deployed infrastructure. The payoff is catching infrastructure bugs in a PR rather than in production.

Read more

Start now free