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 supplies the workflow YAML, the binding cache and the OIDC-to-AWS wiring, while validating synthesized Terraform from CDKTF covers the init -backend=false / validate / fmt -check step and the typed wrapper that runs it across every synthesized stack.

What follows is the reasoning that sits above both guides: which failure classes each stage is actually defending against, why the same assertion belongs at exactly one stage and not two, and how to order the stages so that the cheapest check fails first. The shape is unusual for a Python project because the unit under test is not a function's return value — it is a JSON document, and the thing that consumes that document is a different program written in a different language.

The CDKTF delivery pipeline The CDKTF delivery pipeline: Static checks then Tests then Synth + validate then Plan gate then Deploy Static checks ruff + mypy Tests pytest, no credentials Synth + validate cdk.tf.json Plan gate reviewed diff Deploy protected branch only
Each stage is cheaper and earlier than the one after it; only the last two need cloud credentials.

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.

The reason the laptop workflow feels adequate for so long is that CDKTF hides its own failure surface well. Synthesis almost never fails. A constructor that receives a nonsense value still produces a syntactically valid resource block; a construct extracted into a helper class still synthesizes; a provider argument that was renamed two minor versions ago still emits, because the bindings you compiled against still know it. Everything you got wrong is legal JSON, and the first program with an opinion about it is Terraform — which sees it only after terraform init has downloaded a provider and read its schema.

That gap is where the three failure classes live, and each has a distinct signature. A type regression is a Python-level mistake: a str where the binding wanted a list[str], a config field renamed in one place. It never reaches the cloud, but it costs a full CI cycle to discover if mypy is not the first gate. A graph change is worse because it is invisible in the source diff: rename a construct id, move a resource into a nested Construct, and the derived logical ID changes, so Terraform reads a resource it already manages as one to destroy and one to create. Nothing about the Python diff says "destroy the database". A schema violation is a provider-level rejection — an argument that does not exist, an enum value the API refuses, a required block omitted — and it surfaces as a plan-time error, or occasionally as an apply-time error if the provider only validates during ApplyResourceChange.

There is a fourth class that only appears once you have a pipeline: non-determinism. If synthesis reads the wall clock, a random value, or an unpinned provider schema, then the JSON reviewed on the pull request is not the JSON applied on merge, and every gate downstream is theatre. This is why provider pinning and a committed lockfile are prerequisites rather than nice-to-haves, and why the first thing worth asserting in CI is that two consecutive synthesis runs produce byte-identical output.

What a laptop-only deploy hides What a laptop-only deploy hides: One engineer, one shell with 4 facets. One engineer, one shell Type regressions mypy never ran, so a bad argument reaches synthesis Graph drift a refactor moves logical IDs and nobody reads the diff Schema violations the provider rejects the argument only at plan time Silent replaces a destroy is approved by whoever typed the command
Four failure classes that only become visible once synthesis and the plan are produced by a machine.

The pipeline described here answers each class at the cheapest stage that can see it: types at mypy, graph shape at pytest, legality at terraform validate, and blast radius at the plan gate. Nothing is checked twice, because a check duplicated across two stages is a check nobody maintains.

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.
  • Node.js 20 on the runner: cdktf-cli is an npm package and the cdktf Python library is a jsii proxy that needs a Node child process, so a Python-only image cannot synthesize at all.
  • A remote state backend already configured — see state backend configuration for CDKTF — so CI never writes local state.
  • A .terraform.lock.hcl committed and locked for the runner's platform as well as every developer's, so init in CI resolves the same provider builds.
The toolchain a CDKTF runner needs The toolchain a CDKTF runner needs: layered from Python 3.9+ with the project installed down to Remote backend and an OIDC role. Python 3.9+ with the project installed your constructs, pytest, mypy Node 20 with cdktf-cli the jsii bridge and the synth/diff/deploy commands Terraform 1.5+ init, validate, plan and apply over cdk.tf.json Pinned provider bindings cdktf.json plus a committed lockfile Remote backend and an OIDC role no local state, no static keys on the runner
Miss any layer and the failure surfaces far from its cause — a missing Node install reads as a Python import error.

Two of those deserve elaboration because they are the usual cause of a pipeline that works on one machine and not another. The generated bindings directory is named by codeMakerOutput in cdktf.jsonimports/ in the default Python template, .gen/ in many hand-rolled projects — and whichever it is, CI must either commit it or regenerate it with cdktf get before synthesis. Caching it is fine, but only if the cache key hashes cdktf.json, otherwise a provider version bump restores stale bindings and the runner synthesizes a graph nobody wrote. The lockfile platform set matters because a developer on an Apple Silicon laptop records only darwin_arm64 hashes, and terraform init on a Linux runner then fails rather than silently downloading a different build.

Verify the toolchain before wiring CI:

# CLI: confirm every pipeline tool is present and pinned
cdktf --version
terraform version
node --version
python -m mypy --version
pytest --version
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64

The last line is the one people skip. It rewrites .terraform.lock.hcl to carry checksums for both platforms; commit the result. Without it, a runner reports Error: Failed to install provider followed by the current package for registry.terraform.io/hashicorp/aws 5.60.0 doesn't match any of the checksums previously recorded in the dependency lock file, which reads like tampering and is in fact just a missing platform entry.

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 consequence is that the familiar testing pyramid inverts. In an application, unit tests are numerous and cheap because they exercise pure functions, and integration tests are few and expensive because they need a database. In CDKTF, everything below the apply is pure. Synthesis has no side effects, reads no network, and consumes no credentials, so the "unit" tier can cover the entire program rather than a handful of helpers. What remains expensive is exactly one thing: asking a provider what it thinks, which happens at terraform validate (schema only) and cdktf diff (schema plus live state).

Wall-clock cost of each gate on a 40-resource stack Wall-clock cost of each gate on a 40-resource stack: mypy --strict, unit assertions, snapshot diff, terraform validate, cdktf diff (plan). mypy --strict 9 s unit assertions 4 s snapshot diff 6 s terraform validate 21 s cdktf diff (plan) 95 s
Order the pipeline by cost: the checks that need no credentials finish before the plan starts.

Three Testing helpers cover almost every assertion worth writing, and knowing which one you are using matters because they do different amounts of work:

  • Testing.synth(stack) renders one stack to a JSON string without running validations. It is the fast path and the one to reach for by default.
  • Testing.synth(stack, run_validations=True) additionally runs each construct's validate hook and any Annotations errors, so a construct that guards its own inputs is exercised.
  • Testing.full_synth(stack) writes a real output directory and runs terraform init and validate against it. It needs the Terraform binary and network access to the provider registry, and it is what Testing.to_be_valid_terraform(...) consumes.

The matcher helpers — Testing.to_have_resource, Testing.to_have_resource_with_properties, Testing.to_have_data_source_with_properties — take the synthesized string and return a bool, so they compose with a plain assert. They match on resource type, not on logical ID, which is deliberate: an assertion that a bucket exists with server-side encryption enabled should survive a refactor that renames the construct, whereas an assertion about a specific address should be written explicitly when address stability is the thing you care about.

# tests/test_network_stack.py — assert on the emitted graph, not on Python objects
# CLI: pytest tests/test_network_stack.py -q
import json
from typing import Any

from cdktf import Testing
from infra.network_stack import NetworkStack


def synthesized() -> str:
    app = Testing.app()
    stack = NetworkStack(app, "test", region="eu-west-1", cidr_block="10.0.0.0/16")
    # State implication: Testing.synth never touches a backend or a cloud API.
    return Testing.synth(stack, run_validations=True)


def test_vpc_cidr_is_emitted() -> None:
    doc: dict[str, Any] = json.loads(synthesized())
    vpcs = doc["resource"]["aws_vpc"]
    assert any(v["cidr_block"] == "10.0.0.0/16" for v in vpcs.values())


def test_every_subnet_disables_public_ips() -> None:
    doc: dict[str, Any] = json.loads(synthesized())
    subnets = doc["resource"]["aws_subnet"].values()
    offenders = [s for s in subnets if s.get("map_public_ip_on_launch")]
    assert not offenders, f"subnets auto-assign public IPs: {offenders}"


def test_matcher_helper_form() -> None:
    # Provider note: matches on resource type, so a construct rename does not break it.
    assert Testing.to_have_resource_with_properties(
        synthesized(), "aws_vpc", {"enable_dns_hostnames": True}
    )

The second test above is the shape that pays for itself: a policy expressed as an assertion over the whole graph rather than over one resource. It costs nothing to run, it cannot be forgotten when someone adds a sixth subnet, and it fails with the offending resource printed rather than with a diff of two thousand JSON lines.

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.

The asymmetry is worth stating plainly. Your tests know your intent and nothing about AWS. Terraform knows the provider schema and nothing about your intent. Neither can be derived from the other, and the failure modes when you keep only one are predictable: a project with tests but no validate ships an Error: Unsupported argument to the plan stage on every provider upgrade; a project with validate but no tests ships a correctly-formed graph pointed at the wrong VPC.

What each gate catches and what it cannot What each gate catches and what it cannot: comparison across Catches, Cannot catch. Gate Catches Cannot catch mypy --strict wrong argument types anything decided at synth time unit assertion the graph you intended provider legality snapshot diff unplanned graph drift a mistake already frozen terraform validate illegal attributes wrong but legal wiring cdktf diff destroy and replace actions nothing, if nobody reads it
Intent and legality are different questions; no single gate answers both.

Snapshot testing sits between the two and earns its place by catching the class nothing else sees: an unintended change to the graph produced by a change that looks harmless in review. Extracting three resources into a reusable construct, renaming a keyword argument that happens to be a construct id, or upgrading cdktf itself all rewrite parts of cdk.tf.json. A snapshot turns that into a reviewable diff.

Snapshots are only useful if synthesis is deterministic, and out of the box it is not quite: the emitted "//" metadata block carries the CDKTF version, so a CLI upgrade rewrites every snapshot for no semantic reason. Testing.stub_version replaces that value with a fixed stub, which is the difference between a snapshot suite people read and one they regenerate reflexively.

# tests/test_snapshot.py — freeze the emitted graph, ignoring toolchain noise
# CLI: pytest tests/test_snapshot.py -q   (add --snapshot-update to accept a diff)
import json
from typing import Any

from cdktf import Testing
from infra.network_stack import NetworkStack


def test_network_graph_matches_snapshot(snapshot: Any) -> None:
    # Provider note: stub_version pins the "//" metadata so a cdktf upgrade
    # does not rewrite every golden file.
    app = Testing.stub_version(Testing.app())
    stack = NetworkStack(app, "test", region="eu-west-1", cidr_block="10.0.0.0/16")
    doc = json.loads(Testing.synth(stack))
    assert json.dumps(doc, indent=2, sort_keys=True) == snapshot


def test_logical_ids_are_stable() -> None:
    app = Testing.stub_version(Testing.app())
    stack = NetworkStack(app, "test", region="eu-west-1", cidr_block="10.0.0.0/16")
    doc: dict[str, Any] = json.loads(Testing.synth(stack))
    addresses = {
        f"{rtype}.{logical_id}"
        for rtype, block in doc["resource"].items()
        for logical_id in block
    }
    # State implication: an address that moves means destroy + create on the next apply.
    assert "aws_vpc.main" in addresses

The second test is the cheap insurance against the graph-drift class described above. It asserts the Terraform addresses, not the properties, and it fails in CI on the pull request that moved a construct rather than on the plan that proposed to destroy it. When it fails legitimately — because you meant to move the resource — the fix is a moved block in an override, not a snapshot regeneration.

The legality layer belongs in the same job but after synthesis, and it needs no credentials at all. The full mechanics, including the per-stack loop and the fmt -check companion, are in validating synthesized Terraform from CDKTF.

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.

The gate is the only stage that reads live state, which gives it two properties nothing upstream has. It sees drift: a security group someone edited in the console appears as an update even though no code changed. And it sees blast radius: the difference between "adds one subnet" and "replaces the RDS instance" is invisible in the source diff and unmissable in the plan.

How a plan reaches a human before it reaches the cloud How a plan reaches a human before it reaches the cloud: Pull request → CI job → Remote state → Reviewer. Pull request CI job Remote state Reviewer push commit read and lock current state post plan approve or block
The plan is generated by the pipeline but approved on the pull request, so applying stays tied to code review.

Reading the plan as prose does not scale past about twenty resources, and it certainly does not scale to a reviewer skimming a pull request comment at the end of the day. Save the plan to a file, render it as JSON, and assert on it. terraform plan -detailed-exitcode returns 0 for no changes, 2 for changes pending, and 1 for an error, which is enough to drive the whole gate from a shell. For anything more nuanced — "fail if any resource is being replaced, unless the pull request carries a label" — parse the machine-readable form.

# scripts/plan_gate.py — turn a Terraform plan into a pass/fail decision
# CLI: terraform -chdir=cdktf.out/stacks/NetworkStack show -json tfplan | python scripts/plan_gate.py
import json
import sys
from dataclasses import dataclass


@dataclass(frozen=True)
class Change:
    address: str
    actions: tuple[str, ...]

    @property
    def is_destructive(self) -> bool:
        return "delete" in self.actions


def load_changes(raw: str) -> list[Change]:
    plan = json.loads(raw)
    return [
        Change(rc["address"], tuple(rc["change"]["actions"]))
        for rc in plan.get("resource_changes", [])
        if rc["change"]["actions"] != ["no-op"]
    ]


def main() -> int:
    changes = load_changes(sys.stdin.read())
    for c in changes:
        print(f"{'!!' if c.is_destructive else '  '} {c.address}: {','.join(c.actions)}")
    destructive = [c for c in changes if c.is_destructive]
    # State implication: a ["delete", "create"] pair is a replacement — the resource
    # is destroyed and rebuilt, and any data on it is gone.
    if destructive:
        print(f"plan gate: {len(destructive)} destructive change(s) require explicit approval")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Two rules keep the gate honest. First, the plan that is approved must be the plan that is applied: save it with terraform plan -out=tfplan and apply that file, rather than re-planning at deploy time against state that may have moved. Second, the approval must live where the code review lives. A gate that can be satisfied by clicking a button in the CI tool, without the diff appearing next to the source change that caused it, reproduces the laptop workflow with extra steps.

Step-by-Step: Assembling the Pipeline

The five stages run in cost order. Everything up to and including terraform validate runs with no cloud credentials; only the last two need a role.

The change loop a CDKTF pipeline enforces The change loop a CDKTF pipeline enforces: Edit constructs → Run gates locally → Open pull request → Read the diff → Merge and deploy → repeat. Edit constructs Run gateslocally Open pullrequest Read the diff Merge and deploy
Running the same commands locally that CI runs keeps the pull request feedback loop short.

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
ruff check .
python -m mypy . --strict
pytest tests/ -m "not integration" -q

mypy --strict is worth the initial pain on a CDKTF project specifically because the provider bindings are fully typed. A wrong argument type is a compile-time error rather than a synthesis-time surprise, and the generated classes carry the provider's own optionality, so a required argument you forgot is caught before anything runs.

2. Regenerate the bindings and synthesize

# CLI: derive the bindings from cdktf.json, then emit the configuration
cdktf get
cdktf synth --output cdktf.out
ls cdktf.out/stacks

Run cdktf get even when the bindings are cached; on a cache hit it is close to a no-op, and on a miss it is the only thing standing between you and a synthesis against last month's schema.

3. Validate the emitted configuration

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
terraform -chdir=cdktf.out/stacks/NetworkStack init -backend=false
terraform -chdir=cdktf.out/stacks/NetworkStack validate
terraform fmt -check -recursive cdktf.out

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

4. Gate the plan

On a pull request, produce the plan, render it for a human, and fail the job on anything destructive that has not been explicitly acknowledged. Wiring this to GitHub with OIDC is covered in running CDKTF pipelines in GitHub Actions.

# CLI: PR shows the plan; the exit code decides whether a human must intervene
cdktf diff --stack NetworkStack
terraform -chdir=cdktf.out/stacks/NetworkStack init
terraform -chdir=cdktf.out/stacks/NetworkStack plan -out=tfplan -detailed-exitcode
terraform -chdir=cdktf.out/stacks/NetworkStack show -json tfplan | python scripts/plan_gate.py

Provider note: terraform plan here needs a real backend and real credentials, because it reads state. This is the first stage in the pipeline that does.

5. Deploy from the protected branch only

# CLI: merge applies it non-interactively, on the protected branch
cdktf deploy --stack NetworkStack --auto-approve

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.

With more than one stack, pass each stack id explicitly or drive a job matrix over them. cdktf deploy with no stack argument in a multi-stack app refuses to guess and prints the available ids, which is a good default but a poor thing to discover in a deploy job.

Verification

Confirm the pipeline behaves as designed by running it locally against a throwaway stack, in exactly the order CI will run it:

# 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.

Then prove the property everything else rests on — that synthesis is a pure function of the source:

# CLI: two runs, byte-identical output, or the pipeline is reviewing the wrong artefact
cdktf synth --output out-a >/dev/null
cdktf synth --output out-b >/dev/null
diff -r out-a/stacks out-b/stacks && echo "synthesis is deterministic"

If that diff is non-empty, something in the program reads the clock, a random source, or an environment variable that differs between runs. Fix it before adding snapshot tests, because a snapshot over non-deterministic output fails randomly and gets deleted within a week.

Finally, confirm the gate actually blocks. Push a branch that removes a required argument from a resource and check that the job fails at terraform validate rather than at plan; push one that renames a construct id and check that the address assertion fails at pytest rather than showing up as a replacement in the plan. A gate nobody has ever seen fail is a gate nobody knows the failure output of.

Troubleshooting

Triaging a red CDKTF pipeline Triaging a red CDKTF pipeline: choose among 4 options. Which stage went red? synth Missing .genbindings init Backend reached withno creds validate Unsupported argument diff Unexpected replace
The failing stage narrows the cause to one of four well-known mistakes.

cdktf synth fails in CI but works locally — cause: missing generated bindings. The runner has no bindings directory, so the import fails before synthesis starts, typically as ModuleNotFoundError: No module named 'imports.aws' (or .gen.aws, depending on codeMakerOutput). Fix: run cdktf get before cdktf synth, and cache the output directory keyed on a hash of cdktf.json so a provider bump invalidates it.

terraform validate errors with Error: Backend initialization required, please run "terraform init" — cause: init tried to reach the backend. The validate-only init attempted to configure the remote backend and failed without credentials, leaving the working directory uninitialised. Fix: add -backend=false to that init, since validation reads provider schemas only and never touches state.

terraform init fails with Error: Failed to install provider and a checksum complaint — cause: the lockfile lacks the runner's platform. The message continues the current package for registry.terraform.io/hashicorp/aws 5.60.0 doesn't match any of the checksums previously recorded in the dependency lock file. Fix: run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 locally and commit the updated .terraform.lock.hcl.

Plan shows Error: Unsupported argument naming something you never wrote — cause: an escape hatch or a module variable. add_override writes straight into the emitted JSON without type checking, and TerraformHclModule variable keys are unknown to Python, so both surface at plan time rather than at synthesis. Fix: grep the stack for add_override and compare the module's declared variables against the dictionary you passed.

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. If the only difference is the "//" metadata version, you are missing Testing.stub_version and the suite will keep doing this on every CLI upgrade.

The deploy job fails with Error: Error acquiring the state lock — cause: a previous run died holding the lock. The message names the lock ID and the operation that took it. Fix: confirm no run is genuinely in flight, then terraform force-unlock <lock-id> in the affected stack directory. Do not add -lock=false to the pipeline; it converts an occasional stall into occasional state corruption.

cdktf deploy in CI reports that it found no matching stack — cause: an ambiguous or renamed stack id. With several stacks the CLI refuses to guess and lists what the app produced. Fix: pass --stack <id> explicitly, and derive the id from the same typed configuration your stack constructor uses so a rename cannot drift between code and workflow.

FAQ

Do I need cloud credentials to run CDKTF unit and snapshot tests?

No. 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 and cdktf deploy stages, which read state and call cloud APIs. Testing.full_synth is the exception — it shells out to terraform init, so it needs registry access even though it needs no cloud role.

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.

How do I keep CI synthesis deterministic across runs?

Pin every provider in cdktf.json, commit .terraform.lock.hcl, cache the generated bindings on a key that hashes cdktf.json, and call Testing.stub_version in snapshot tests. Then assert determinism directly by synthesizing twice into different directories and diffing them.

Why does my snapshot test fail after upgrading cdktf-cli when no infrastructure changed?

Because the emitted "//" metadata block records the CDKTF version, and it lands in the JSON your snapshot froze. Wrap the app in Testing.stub_version(Testing.app()) so the version is replaced with a fixed stub, and the snapshot then only changes when the resource graph does.

Can a snapshot test replace terraform validate?

No, and the reverse is also false. A snapshot proves the JSON matches what you approved last time, including any illegal attribute you approved by mistake. terraform validate proves the JSON is legal for the pinned provider schema, including graphs that are legal and completely wrong. They fail on disjoint sets of bugs, so run both.

How should I test a construct that is reused across several stacks?

Instantiate it inside a throwaway stack in the test — Testing.app(), a minimal TerraformStack subclass, then the construct — and assert on the synthesized JSON. That exercises the same code path the real stacks use, and it keeps the construct's tests independent of any one stack's configuration. Reusable construct design itself is covered under Python constructs and modules.