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:
- Synthesis: your code runs and produces Terraform JSON in
cdktf.out/ - 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 --updateSnapshotCommit 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 integrationPython 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 JSONCommon 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.