Snapshot Testing CDKTF Stacks with pytest

Snapshot testing a CDKTF stack synthesizes it to Terraform JSON in-process and compares the result against a stored golden file, so any unintended change to the generated configuration fails the test before it ever reaches a plan. This how-to is part of Testing Python Infrastructure Code under Python IaC fundamentals and strategy, and it is the snapshot layer of the testing pyramid for CDKTF projects.

Context

CDKTF's architecture and synthesis step turns your Python constructs into Terraform JSON. Because that JSON is deterministic, it is an ideal subject for a snapshot test: synthesize once, save the output as a golden file, and on every later run fail if the new output differs. This catches accidental changes — a flipped flag, a renamed resource, a dropped tag — that compile and synthesize cleanly but alter what Terraform would apply. cdktf.Testing.synth runs synthesis without writing to disk or touching a provider, so the test stays fast and hermetic.

Context Context: Context with 4 facets. Context cdktf.Testing key element CDKTF key element Python key element Terraform JSON key element
Context: how cdktf.Testing, CDKTF, Python relate in this pattern.

What you are snapshotting is a specific document with a specific shape, and knowing that shape decides which parts are worth asserting on.

What Testing.synth returns What Testing.synth returns: layered from "//" metadata down to "output". "//" metadata CDKTF version and stack name — pure bookkeeping "terraform" backend and required_providers, rarely changes "provider" provider blocks and their arguments "resource" every resource your constructs declared "output" TerraformOutput values consumers depend on
Only the lower three sections state intent; the top one changes when the tool does.

Only two of those five sections describe your intent. resource and output are what you wrote; provider and terraform are configuration that changes rarely and deliberately; and the // block is bookkeeping the tool emits about itself. Snapshotting the whole document therefore mixes a high-signal diff with a low-signal one, which is why the first real snapshot suite anyone writes fails on an unrelated dependency bump and gets quietly disabled a week later. The fix is not to snapshot less — it is to normalise the noise out before comparing, which the next section covers.

The other thing worth understanding before writing the first test is that Testing.synth is not a plan. It calls no provider, reads no state and validates nothing against a schema by default, so a resource argument that Terraform would reject is happily serialised into the JSON and happily matches its snapshot. A snapshot test proves the configuration you generate is the configuration you meant to generate. It cannot prove that configuration is valid, and the two failures need different tools.

Which test layer catches which failure Which test layer catches which failure: choose among 4 options. A change to the stack synth Snapshot: the configtext moved validate Schema and typeerrors plan Replacement or statedrift apply Provider and quotarejection
A snapshot proves you generated what you meant, not that Terraform will accept it.

Prerequisites

Prerequisites Prerequisites: layered from Testing.synth down to Terraform. Testing.synth Python Terraform
Prerequisites: the building blocks this section assembles.
  • Python 3.9+, cdktf>=0.20, and constructs>=10.
  • The generated provider bindings under .gen/ (run cdktf get first).
  • pytest>=7; no Terraform binary and no cloud credentials are needed for Testing.synth.
  • A committed tests/__snapshots__/ directory for golden files.

Implementation

1. Synthesize a stack in isolation with Testing.synth

Implementation Implementation: 1. Synthesize a then 2. Assert specific then 3. Compare against 1. Synthesize a 2. Assert specific 3. Compare against
Implementation: the stages run left to right — 1. Synthesize a, 2. Assert specific, 3. Compare against.

Construct the stack inside a Testing.app() scope and call Testing.synth to get the Terraform JSON as a string.

# CLI: pytest tests/test_stack_snapshot.py -v
# State implication: Testing.synth runs in-memory; it writes no cdktf.out and locks no state.
import json
from cdktf import Testing, TerraformStack
from constructs import Construct
# Provider note: import provider bindings from .gen produced by `cdktf get`.
from imports.aws.provider import AwsProvider
from imports.aws.s3_bucket import S3Bucket

class StorageStack(TerraformStack):
    def __init__(self, scope: Construct, sid: str, *, bucket_name: str) -> None:
        super().__init__(scope, sid)
        AwsProvider(self, "aws", region="us-east-1")
        S3Bucket(self, "data", bucket=bucket_name, force_destroy=False)

def synth_storage(bucket_name: str) -> dict:
    app = Testing.app()
    stack = StorageStack(app, "storage", bucket_name=bucket_name)
    return json.loads(Testing.synth(stack))

2. Assert specific keys, or the whole golden file

For targeted checks, assert on a single resource block; for full coverage, compare the entire synthesized document against a snapshot.

# CLI: pytest tests/test_stack_snapshot.py::test_bucket_block -v
def test_bucket_block() -> None:
    synth = synth_storage("acme-data")
    bucket = synth["resource"]["aws_s3_bucket"]["data"]
    assert bucket["bucket"] == "acme-data"
    assert bucket["force_destroy"] is False  # guards against accidental data loss

3. Compare against a committed golden file

Store the first run's output and diff every later run against it; this is what catches drift in the generated configuration.

# CLI: pytest tests/test_stack_snapshot.py::test_full_snapshot -v
# CLI to refresh after an intentional change: UPDATE_SNAPSHOTS=1 pytest tests/ -q
import json, os
from pathlib import Path

SNAP = Path(__file__).parent / "__snapshots__" / "storage.json"

def test_full_snapshot() -> None:
    current = synth_storage("acme-data")
    if os.getenv("UPDATE_SNAPSHOTS"):
        SNAP.write_text(json.dumps(current, indent=2, sort_keys=True))
    expected = json.loads(SNAP.read_text())
    assert current == expected  # fails on any unintended synthesis change

4. Assert with CDKTF's own matchers where the rule matters

A golden file tells you something changed; a matcher tells you what must always be true. CDKTF ships assertions that operate on the synthesized string, and they are the right tool for the handful of invariants that should fail loudly no matter what else the diff contains:

# CLI: pytest tests/test_stack_invariants.py -v
# Provider note: these matchers take the raw string from Testing.synth, not a parsed dict.
from cdktf import Testing


def test_bucket_is_never_force_destroyed() -> None:
    app = Testing.app()
    stack = StorageStack(app, "storage", bucket_name="acme-data")
    synthesized: str = Testing.synth(stack)

    assert Testing.to_have_resource(synthesized, "aws_s3_bucket")
    assert Testing.to_have_resource_with_properties(
        synthesized, "aws_s3_bucket", {"force_destroy": False}
    )


def test_configuration_is_schema_valid() -> None:
    # State implication: full_synth writes a real directory and runs terraform init,
    # so this test needs the Terraform binary and is slower than Testing.synth.
    app = Testing.app()
    stack = StorageStack(app, "storage", bucket_name="acme-data")
    assert Testing.to_be_valid_terraform(Testing.full_synth(stack))

Keep the two kinds of test in separate files with separate markers. The matcher tests are cheap and should run on every save; to_be_valid_terraform requires Testing.full_synth, which writes to a temporary directory and shells out to Terraform, and belongs behind a pytest.mark.slow that CI runs and the inner loop skips.

Making Synthesis Deterministic

A snapshot suite is only as useful as its signal-to-noise ratio, and CDKTF emits three things that change without your code changing.

The first is the tool's own version. Every synthesized document carries a metadata block naming the CDKTF version that produced it, so upgrading cdktf from 0.20.7 to 0.20.8 rewrites every golden file in the repository and buries the one real change among them. Testing.stub_version exists precisely for this and replaces the recorded version with a fixed placeholder:

# conftest.py — one fixture that removes every known source of snapshot churn
# CLI: pytest -q
from typing import Any, Dict
import json
import pytest
from cdktf import App, Testing


@pytest.fixture()
def app() -> App:
    # Provider note: stub_version pins the CDKTF version recorded in the "//" block,
    # so a patch-level tool upgrade no longer rewrites every golden file.
    return Testing.stub_version(Testing.app())


def normalize(synthesized: str) -> Dict[str, Any]:
    """Strip bookkeeping that is not a statement of intent."""
    doc: Dict[str, Any] = json.loads(synthesized)
    doc.pop("//", None)
    for block in doc.get("resource", {}).values():
        for body in block.values():
            body.pop("//", None)
    return doc

The second is token numbering. Deferred values synthesize as sentinels such as ${TfToken[TOKEN.9]}, and the counter is assigned in construct-creation order across the whole app. Adding an unrelated resource earlier in the program shifts every later token by one and produces a diff across resources you never touched. Building each stack in its own Testing.app() — rather than sharing one app across a test module — keeps the counter scoped to the stack under test.

The third is anything your own code makes non-deterministic: datetime.now() in a tag, uuid4() in a bucket suffix, a set iterated into a list. These belong behind an injected parameter so the test can pin them, which is the same discipline that makes the stack reproducible in the first place — the reasoning in idempotency and drift detection in Python IaC applies to the test suite as directly as it applies to the infrastructure.

With those three handled, a golden-file comparison becomes trustworthy enough to gate a merge:

# CLI: pytest tests/test_stack_snapshot.py -q
# CLI to refresh after a reviewed change: UPDATE_SNAPSHOTS=1 pytest tests/ -q
import json
import os
from pathlib import Path
from cdktf import App, Testing

SNAP = Path(__file__).parent / "__snapshots__" / "storage.json"


def test_storage_snapshot(app: App) -> None:
    stack = StorageStack(app, "storage", bucket_name="acme-data")
    current = normalize(Testing.synth(stack))

    if os.getenv("UPDATE_SNAPSHOTS"):
        SNAP.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n")
    elif not SNAP.exists():
        # Fail loudly rather than creating a baseline nobody reviewed.
        raise AssertionError(f"missing snapshot {SNAP}; run with UPDATE_SNAPSHOTS=1 locally")

    assert current == json.loads(SNAP.read_text())

The elif branch matters more than it looks. Without it, a test that runs for the first time in CI writes its own baseline and passes, which means a brand-new stack is never actually checked by the suite that was supposed to check it.

Verification

Run the suite; a green run confirms the synthesized Terraform JSON matches the committed golden file exactly.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# CLI: run snapshot tests, then intentionally refresh after a reviewed change
pytest tests/ -q
UPDATE_SNAPSHOTS=1 pytest tests/ -q   # only when the diff is expected and reviewed
# Provider note: no Terraform binary invoked; this asserts synthesis output, not a plan.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 3 facets. Where it breaks cdktf.json watch this boundary Edge Cases watch this boundary JSON watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Non-deterministic values poison snapshots. Timestamps, random suffixes, or unsorted maps make the golden file differ on every run. Pin such inputs in tests and dump JSON with sort_keys=True so key ordering is stable.

A blanket snapshot update hides real regressions. Running UPDATE_SNAPSHOTS=1 blindly accepts whatever changed, defeating the test. Always read the diff first and only refresh when the change is intended and reviewed.

Stale .gen bindings change the output. If a teammate bumps a provider version and regenerates, the synthesized JSON shifts. Pin provider versions in cdktf.json and treat .gen regeneration as a deliberate, reviewed step so snapshots stay meaningful.

Operational Notes

Snapshot tests are a tripwire, not a specification: they tell you that the synthesized Terraform changed, not whether the change is correct. That is exactly what you want for infrastructure, where an innocuous refactor can silently alter a resource address and trigger a replacement. Commit the snapshot alongside the code so every diff is reviewable in the pull request.

Snapshot workflow Snapshot workflow: synth then compare then review diff then update snapshot synth compare review diff update snapshot
Each run synthesizes, diffs against the stored snapshot, and fails on any unexpected change.

The common failure mode is a noisy snapshot that changes on every provider-binding bump. Pin the provider version so the baseline is stable, and when a bump is intentional, regenerate the snapshot in the same commit that raises the pin, with a reviewer confirming the diff is only what the upgrade should cause. Pair snapshots with unit tests that assert on specific properties for the rules that must never change.

Make the refresh mechanism impossible to trigger accidentally in CI. UPDATE_SNAPSHOTS is an environment variable, and environment variables leak into pipelines; a job that inherits it turns the entire suite into a no-op that reports green. Assert against it explicitly rather than trusting nobody will set it:

# conftest.py — a snapshot refresh must never happen on a CI runner
# CLI: pytest -q
import os
import pytest


def pytest_configure(config: pytest.Config) -> None:
    if os.getenv("UPDATE_SNAPSHOTS") and os.getenv("CI"):
        raise pytest.UsageError(
            "UPDATE_SNAPSHOTS is set on a CI runner; snapshots must be refreshed "
            "locally and committed, not regenerated during the build"
        )

Multi-stack applications need one snapshot per stack, not one per app. Testing.synth takes a single TerraformStack, which lines up with how CDKTF deploys, and parametrising over environments keeps the per-environment differences visible as separate files instead of hidden inside one large document:

# CLI: pytest tests/test_environments.py -q
import pytest
from cdktf import App, Testing


@pytest.mark.parametrize("env,bucket", [("dev", "acme-data-dev"), ("prod", "acme-data-prod")])
def test_each_environment_snapshot(app: App, env: str, bucket: str) -> None:
    stack = StorageStack(app, f"storage-{env}", bucket_name=bucket)
    current = normalize(Testing.synth(stack))
    snap = Path(__file__).parent / "__snapshots__" / f"storage-{env}.json"
    assert current == json.loads(snap.read_text())

Review discipline is what turns the golden files from noise into a control. A pull request that touches tests/__snapshots__/ without touching stack code is a red flag worth an explicit question, and a pull request that touches stack code without touching a snapshot means either the change was genuinely additive elsewhere or the test does not cover the code that changed. Both cases are worth a moment in review, and both are visible in the diff without anyone running anything.

Finally, keep the suite honest about what it does not cover. Snapshot tests never contact AWS, so they cannot catch a bucket name that is already taken, an IAM policy the API rejects, or a subnet that has no free addresses. They belong at the bottom of the pyramid alongside the mock-based tests described in testing Python infrastructure code, with terraform validate above them and a plan against real state above that. Each layer is cheap in proportion to how little it knows.

FAQ

How is a CDKTF snapshot test different from terraform plan?

A snapshot test compares the synthesized Terraform JSON against a stored file with no provider or backend involved, so it is fast and hermetic. terraform plan evaluates that JSON against real state and provider APIs to compute changes. The snapshot catches configuration drift; the plan catches state drift.

Where should golden files live?

Commit them under tests/__snapshots__/ alongside the tests so they are versioned and reviewed in pull requests. The diff on the golden file is the signal that something changed.

Can I snapshot test individual constructs instead of whole stacks?

Yes. Wrap the construct in a throwaway TerraformStack inside Testing.app(), synthesize, and assert on its resource block — the same pattern used for reusable constructs in the CDKTF workflows and Terraform synthesis section.

Why does the snapshot change after upgrading cdktf even though my code did not?

The synthesized document records the CDKTF version in its // metadata block, so a patch-level upgrade rewrites every golden file. Wrap the app in Testing.stub_version(Testing.app()) to pin that field, or strip the // key in a normalisation step before comparing.

Should I snapshot the whole document or assert on specific resources?

Both, for different reasons. The whole-document snapshot is a tripwire that catches changes nobody intended; the targeted matchers — Testing.to_have_resource_with_properties and friends — encode rules that must hold regardless of what else moves. A suite with only the first tells you something changed but not whether anything important broke.

Do snapshot tests need AWS credentials or the Terraform binary?

Testing.synth needs neither: it runs synthesis in memory and returns a string. Testing.full_synth writes a real directory and runs terraform init, so it needs the binary and a network path to the provider registry, though still no cloud credentials. Keep them in separate test files so the fast ones stay runnable offline.