Testing Terraform with Python — pytest, Plan Assertions, and the Contract Test
Infrastructure code is the code whose test bill can exceed its value if you test it the naive way.
§ IFrame
Infrastructure code has one property application code does not: running it costs money and changes the world. A unit test for a sorting function runs a thousand times a second and touches nothing. A test that actually applies a Terraform module builds a real load balancer in a real account and bills you for it. So the interesting question for testing infrastructure is not how to run the code. It is how to check the code is correct without paying the full price of running it.
Terraform hands you the answer for free, and most teams walk past it. Before apply touches anything, plan computes the exact set of changes it intends to make. That plan can be written out as a machine-readable JSON document. The plan is a testable artifact. You can parse it in Python and assert against the intended change without a single resource being created. This lesson builds that discipline in pytest: run the plan, read the JSON, and hold the module to a contract about what it is allowed to do.
This is the Python-touching-Terraform seam for the deep-mastery track's first day. The Ops lesson protects the record of what already happened. This lesson tests the proposal of what will happen next.
§ IILanguage Idiom — pytest Over a Subprocess, Asserting on JSON
Python's fit here comes from three ordinary features working together, none of them exotic. The subprocess module runs the Terraform binary and captures its output. The json module parses the plan Terraform emits. And pytest's plain assert statement, with its rewriting that shows you both sides of a failed comparison, turns a parsed plan into readable pass-or-fail checks. The book that grounds this lesson, Python for DevOps, makes the same point in its pytest chapter: infrastructure testing is mostly the discipline of running a tool, capturing its structured output, and asserting on that output with the same pytest patterns you would use on any other data.
The Terraform side is two commands. terraform plan -out=plan.tfplan produces a binary plan file. terraform show -json plan.tfplan converts that binary into JSON on standard output. The JSON has a stable, documented shape. The key most tests care about is resource_changes, an array where each entry describes one resource, its address such as aws_s3_bucket.data, and a change object whose actions list says what Terraform will do to it: ["create"], ["update"], ["delete"], or ["create", "delete"] for a replacement.
The idiom, then, is a fixture that runs init and plan once, parses the JSON, and hands the parsed dictionary to every test. Each test asks one question of that dictionary. Reading the plan is cheap, deterministic, and free of any cloud side effect, so the whole suite runs in seconds and bills nothing.
§ IIICode Worked Example — A Contract Test for an S3 Module
Take a module that is supposed to create one encrypted, versioned S3 bucket and nothing else. A contract test pins that promise. If a future edit adds a second bucket, turns encryption off, or proposes destroying something, the test fails before the change reaches an account.
Start with the fixture. It runs Terraform in the module directory, produces the JSON plan, parses it, and yields the dictionary. Notice it uses a context manager for the temp directory, which extends the resource-scoping instinct from the 07-14 spend-governor lesson: the region where a resource is held open is exactly the region where cleanup must be guaranteed.
import json
import subprocess
from pathlib import Path
import pytest
def _run(cmd, cwd):
result = subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout
@pytest.fixture(scope="module")
def plan(tmp_path_factory):
module_dir = Path("modules/data-bucket")
_run(["terraform", "init", "-input=false"], module_dir)
_run(["terraform", "plan", "-out=tfplan", "-input=false"], module_dir)
raw = _run(["terraform", "show", "-json", "tfplan"], module_dir)
return json.loads(raw)
The fixture is scoped to the module so the plan runs once for the whole file rather than once per test. Every test below receives the same parsed plan and interrogates it.
The first test holds the count. The module promises exactly one bucket, so a plan proposing two is a contract breach whether or not the second bucket is harmless.
def _changes(plan, resource_type):
return [
rc for rc in plan["resource_changes"]
if rc["type"] == resource_type
]
def test_creates_exactly_one_bucket(plan):
buckets = _changes(plan, "aws_s3_bucket")
assert len(buckets) == 1
def test_nothing_is_destroyed(plan):
for rc in plan["resource_changes"]:
actions = rc["change"]["actions"]
assert "delete" not in actions, rc["address"]
The second test is the one that saves careers. A plan that quietly proposes destroying a production database reads as an innocent green diff to a tired engineer at the end of a long day. The test_nothing_is_destroyed check makes that impossible to miss: any delete in any resource's action list fails the suite and names the offending address. When a destroy is genuinely intended, you change the test deliberately, which is the point. The dangerous action now requires a conscious edit to the safety net rather than slipping through unremarked.
The third test reaches into a resource's planned values to hold a security property. Encryption on the bucket is not optional in this contract, so the test asserts the planned configuration carries it.
def test_bucket_encryption_is_enabled(plan):
encryption = _changes(
plan, "aws_s3_bucket_server_side_encryption_configuration"
)
assert len(encryption) == 1
after = encryption[0]["change"]["after"]
rule = after["rule"][0]["apply_server_side_encryption_by_default"][0]
assert rule["sse_algorithm"] in ("aws:kms", "AES256")
Every one of these tests reads the plan and touches no cloud. The bucket is never created. The suite proves the module's intended change matches its contract, and it does so in the seconds it takes to run a plan.
§ IVConnection to Today's Ops Lesson
The Ops lesson drew Terraform as a difference engine over three pictures: config, world, and state. The plan is the fourth artifact, the computed difference itself, made concrete and written to disk. That lesson protects state, the record of what already happened. This lesson tests the plan, the proposal of what happens next. They guard the two moments where Terraform is most dangerous. State is dangerous because losing or corrupting it makes the tool forget what it owns; the lock defends that. The plan is dangerous because an apply executes it against the real world; the contract test defends that. Assert on the plan, not the cloud, and the mistake is caught in the one place where catching it is free.
There is a policy-as-code layer above these hand-written assertions, and a candid note about it. Tools like checkov scan a plan for known misconfigurations, an open bucket, an unencrypted volume, a wildcard IAM policy, without you writing the assertion yourself. The vault holds no canonical text on policy-as-code today, so I am grounding this paragraph in the tools' primary documentation rather than a book on the shelf, and I have logged the gap for acquisition. The distinction worth keeping: checkov enforces general best practice across every module; the pytest contract test enforces this module's specific promise. You want both, and they do not replace each other.
§ VPrior-Lesson Reach
The 07-14 context-manager lesson built a with-block whose exit path was guaranteed to run even when the body raised. The temp-directory and plan-file handling here inherits that instinct: anything opened must be closed on every path out, including the failure path, or a test suite leaves binary plan files and init directories scattered behind it. Scope is where cleanup is owed.
The 06-23 hashlib-and-HMAC lesson made data integrity a checkable property rather than a hope. A contract test does the same for infrastructure intent. There, a MAC proved a message had not changed since it was signed. Here, an assertion proves a module's plan has not drifted from what its authors promised. Both convert a property you would otherwise trust into a property a machine verifies on every run, and both fail loud rather than passing quiet.
§ VIClosing
Infrastructure code is the code whose test bill can exceed its value if you test it the naive way. Terraform spares you that by computing the plan before it acts and by writing that plan as JSON you can read. The plan is a testable artifact. Parse it in Python, and a module's promises become pytest assertions: exactly one bucket, encryption on, nothing destroyed. Each assertion runs in seconds, touches no account, and fails by name when the module breaks its word. The discipline is one sentence. Assert on the plan, not on the cloud, and catch the mistake where catching it is free.
Take any Terraform module you own. Run terraform show -json on its plan and look at resource_changes. Write one test that fails if anything is destroyed. That single assertion is worth more than most of the monitoring you will add later, because it stops the loss instead of reporting it.
Paired lessons → Ops 01-Earth-DevOps/Synthesis-Lessons/2026-07-22-terraform-state-... · Cert Cert-Prep/HashiCorp/2026-07-22-terraform-associate-003-...
Filed 2026-07-22 Wednesday Fajr · Dev Synthesis lesson · Python-touching-Terraform, TF track day 0
Backward-Synergy-Reach → context-manager spend governor (07-14) · hashlib/HMAC integrity (06-23)
Grounded in Python for DevOps Ch8 · checkov knowledge-gap logged · HTML shipped in-cycle · earth-accent