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.
What you are snapshotting is a specific document with a specific shape, and knowing that shape decides which parts are worth asserting on.
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.
Prerequisites
- Python 3.9+,
cdktf>=0.20, andconstructs>=10. - The generated provider bindings under
.gen/(runcdktf getfirst). pytest>=7; no Terraform binary and no cloud credentials are needed forTesting.synth.- A committed
tests/__snapshots__/directory for golden files.
Implementation
1. Synthesize a stack in isolation with Testing.synth
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.
# 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
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.
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.
Related
- Testing Python Infrastructure Code — the testing pyramid this snapshot layer fits into.
- Unit Testing Pulumi Programs with Mocks — the equivalent fast layer for Pulumi programs.
- CDKTF Architecture & Synthesis — how Python constructs become the Terraform JSON these tests assert against.