CDKTF Testing and CI/CD

Shipping CDK for Terraform safely means gating every change behind a deterministic pipeline: type checking, unit and snapshot tests, synthesis, terraform validate, a plan review, then deploy. This page covers how to assemble that pipeline for Python CDKTF projects as part of the broader CDKTF Workflows & Terraform Synthesis practice, and links the two task guides you will need to run it end to end: running CDKTF pipelines in GitHub Actions and validating synthesized Terraform from CDKTF.

CDKTF CI/CD pipeline stages A left-to-right pipeline: lint and mypy, then unit and snapshot tests, then cdktf synth, then terraform validate, then a plan gate that requires approval, then cdktf deploy. lint + mypy static checks unit + snapshot pytest cdktf synth cdk.tf.json tf validate schema check plan gate approve PR cdktf deploy on main
The CDKTF delivery pipeline: static analysis and tests run first, synthesis produces the HCL JSON that Terraform validates, and a manual plan gate guards the deploy.

Problem Framing

Without a pipeline, CDKTF changes are validated only by whoever runs cdktf deploy on a laptop. That hides three failure classes: type regressions that survive into synthesis, construct logic changes that silently alter the emitted resource graph, and provider schema violations that only surface during a real plan. The cost lands at the worst time — during an apply against production state. A disciplined pipeline moves each of those checks left, so a pull request that would corrupt state never reaches the deploy stage. This is the CDKTF-specific complement to the language-agnostic guidance in Testing Python Infrastructure Code, and it builds directly on the CDKTF architecture and synthesis model that turns Python constructs into cdk.tf.json.

Prerequisites

  • Python 3.9+ with the project installed in editable mode (pip install -e . or poetry install).
  • cdktf-cli and a pinned provider set in cdktf.json so synthesis is reproducible across runners.
  • mypy, pytest, and the Terraform CLI on the runner's PATH.
  • A remote state backend already configured — see state backend configuration for CDKTF — so CI never writes local state.

Verify the toolchain before wiring CI:

# CLI: confirm every pipeline tool is present and pinned
cdktf --version
terraform version
python -m mypy --version
pytest --version

Concept Explanation

How CDKTF Testing Differs from Application Testing

CDKTF code does not call cloud APIs at synthesis time; it builds an in-memory construct tree and serializes it. That makes the unit boundary the synthesized JSON, not a live resource. cdktf.Testing.synth(stack) returns the same JSON your pipeline would hand to Terraform, so assertions run in milliseconds with no credentials. Snapshot tests capture that JSON as a golden file and fail when the emitted graph drifts — the technique covered in depth on the fundamentals side.

The Two Validation Layers

There are two distinct validations, and confusing them is a common mistake. Unit and snapshot tests assert that your Python produces the resource graph you intended. terraform validate against the synthesized output asserts that the graph is legal for the pinned provider schema. You need both: a snapshot test will happily freeze an invalid attribute, and terraform validate will happily pass a graph that wires up the wrong subnet.

# CLI: pytest tests/test_synth.py
# State implication: Testing.synth never touches a backend or cloud API.
import json
from cdktf import Testing
from infra.network_stack import NetworkStack


def test_vpc_cidr_is_emitted() -> None:
    app = Testing.app()
    stack = NetworkStack(app, "test", region="eu-west-1", cidr_block="10.0.0.0/16")
    synthesized: dict[str, object] = json.loads(Testing.synth(stack))
    resources = synthesized["resource"]["aws_vpc"]
    assert any(v["cidr_block"] == "10.0.0.0/16" for v in resources.values())

The Plan Gate

Synthesis and terraform validate prove a change is well-formed; they do not prove it is safe to apply. A cdktf diff (which runs terraform plan under the hood) shows the exact create/update/destroy set. Routing that plan into a required pull-request approval — the plan gate — is what stops an accidental force-replace of a database from auto-merging.

Step-by-Step Implementation

Step 1 — Run Static Analysis and Tests

Type checking and pytest run with no cloud access, so they belong first and can run on every commit.

# CLI: fast, credential-free gate that runs on every push
python -m mypy . --strict
pytest tests/ -m "not integration"

Step 2 — Synthesize and Validate

Produce the HCL JSON, then validate it against the pinned providers. The full mechanics live in validating synthesized Terraform from CDKTF.

# CLI: synth then validate the emitted graph against provider schemas
cdktf get
cdktf synth --output cdktf.out
terraform -chdir=cdktf.out/stacks/NetworkStack init -backend=false
terraform -chdir=cdktf.out/stacks/NetworkStack validate

State implication: -backend=false keeps init from contacting the remote backend, so validation stays read-only and needs no state credentials.

Step 3 — Gate the Plan, Then Deploy

On a pull request, post the diff for review; on merge to the default branch, deploy. Wiring this to GitHub with OIDC is covered in running CDKTF pipelines in GitHub Actions.

# CLI: PR shows the plan; merge applies it non-interactively
cdktf diff --stack NetworkStack          # plan gate: human reviews this on the PR
cdktf deploy --stack NetworkStack --auto-approve   # runs only on the protected branch

Provider note: --auto-approve is safe only because the plan was already reviewed at the gate. Never skip the gate to "speed up" a deploy.

Verification

Confirm the pipeline behaves as designed by running it locally against a throwaway stack:

# CLI: full local dry run of the pipeline, stopping before apply
python -m mypy . --strict && \
  pytest tests/ -m "not integration" && \
  cdktf synth && \
  terraform -chdir=cdktf.out/stacks/NetworkStack init -backend=false && \
  terraform -chdir=cdktf.out/stacks/NetworkStack validate && \
  cdktf diff --stack NetworkStack

A clean run prints Success! The configuration is valid. from validate and a No changes (or an expected, reviewable plan) from cdktf diff.

Troubleshooting

cdktf synth fails in CI but works locally — cause: missing generated bindings. The runner has no .gen directory. Fix: run cdktf get before cdktf synth, and cache .gen keyed on a hash of cdktf.json.

terraform validate errors with Backend initialization required — cause: init tried to reach the backend. Fix: add -backend=false to the validate-only init, since validation does not need state.

Snapshot test fails after a legitimate refactor — cause: the golden file is stale. Fix: regenerate the snapshot, then review the diff carefully before committing so an unintended graph change is not rubber-stamped.

Frequently Asked Questions

Do I need cloud credentials to run CDKTF unit and snapshot tests? No. cdktf.Testing.synth builds and serializes the construct tree in memory without contacting any provider, so those tests run with zero credentials. Credentials are only needed at the cdktf diff/cdktf deploy stages, which talk to the backend and cloud APIs.

Where should the plan gate live — in the pipeline or in the version-control review? Both. Run cdktf diff in the pipeline so the plan is generated reproducibly, but post it onto the pull request and require a human approval there. That keeps the decision to apply tied to code review rather than to whoever has CLI access.

Should cdktf synth and terraform validate run on pull requests or only on merge? On pull requests. The whole point is to catch synthesis and schema errors before merge. Reserve only the cdktf deploy step for the protected branch after the plan has been approved.

How do I keep CI synthesis deterministic across runs? Pin every provider in cdktf.json, commit the lockfile, and cache the generated .gen bindings. Unpinned providers let a runner pull a newer schema and produce a different graph than the one a developer tested.