Terraform Testing and Validation — validate, custom conditions, check blocks, terraform test
Four gates stand between a config and production. The exam asks which gate catches which failure. So does every incident review.
Each gate is cheaper than the one after it and blinder than the one after it. Validate proves the text, plan proves the graph, apply proves the world.
§ IFrame
Three cert lessons drilled the Associate 003 perimeter: state and the core workflow on 07-22, modules and configuration on 07-25, providers and HCP Terraform on 07-28. Today drills the quality gates that stand inside that perimeter, where the Associate blueprint (Objective 6's terraform validate, Objective 8's validations and custom conditions) meets the Authoring & Operations Professional exam, which assumes you can wield the native terraform test harness the Associate only brushes. The Sovereign's sit sequence puts Terraform Pro first, so today carries Associate coverage with Pro-depth weighting on the harness.
Coin the shape first, then unpack it: the four gates. Gate one, validate, proves the config parses and type-checks. Gate two, validation blocks, prove each input is sane by itself. Gate three, custom conditions, prove the assumptions and guarantees hold at plan and apply. Gate four, terraform test, proves the module does what it claims by running it.
§ IIGate One — terraform validate (Objective 6)
terraform validate checks syntax, type consistency, and internal references without touching state, credentials, or any provider API. It confirms that every block parses, every reference points at something declared, every argument matches its declared type. It runs offline, in milliseconds, after terraform init has supplied the providers whose schemas it checks against.
What it does not do is the exam's favorite trap. Validate does not contact the cloud, so it accepts an instance type that does not exist, a CIDR that overlaps a live network, and a bucket name already taken globally. All three are schema-legal and world-illegal. And the world-facing blindness runs one gate deeper than most candidates expect: Brikman's tips chapter keeps a section titled "Valid Plans Can Fail," and the sentence generalizes. A config that validates can produce a plan, and a plan that computes can still die at apply on quota, IAM, or a name collision, because plan reads state and asks providers to predict; only apply asks the world to commit.
§ IIIGates Two and Three — validation Blocks and Custom Conditions (Objective 8)
A validation block lives inside a variable block: a condition expression over that variable plus an error_message. Fail the condition and the run stops before any resource is evaluated, with your message instead of a stack of downstream errors.
variable "env" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "env must be one of dev, staging, prod."
}
}
The idiom set is small and the exam draws from it: contains for enums, can(regex(...)) for shape checks, alltrue([for ...]) for per-element checks over collections. One rule carries most of the questions on this block, and it is the rule the 07-25 lesson met at the module boundary: a validation block sees its own variable and nothing else. Cross-variable rules do not fit in a validation block. They belong to the next gate.
Preconditions and postconditions attach to resources, data sources, and outputs inside a lifecycle block, and Brikman's production-grade chapter gives them their cleanest statement: a precondition checks an assumption the block depends on, a postcondition checks a guarantee the block makes to others. Preconditions may read any variable, local, or data source, which is exactly what the cross-variable rule needs. Postconditions may additionally read self, the resource's own resolved attributes.
resource "google_storage_bucket" "site" {
name = "hedronite-site-${var.env}"
location = var.region
lifecycle {
precondition {
condition = var.env != "prod" || var.versioning_enabled
error_message = "prod buckets must enable versioning."
}
postcondition {
condition = self.uniform_bucket_level_access
error_message = "bucket must enforce uniform bucket-level access."
}
}
}
Timing is the exam's second favorite trap on this surface. Conditions evaluate at plan time when their inputs are known then, and at apply time when they depend on resolved attributes like self. A postcondition on self can therefore stop an apply midway: the bucket exists, the guarantee failed, the run halts and reports. That is the design working as intended. Better a halted apply than a consumer inheriting a broken guarantee.
The third body in this gate is the check block, added after conditions and distinct from both. A check is a top-level block holding assertions (optionally with its own scoped data source) that run at the end of every plan and apply. A failed check reports a warning and does not stop anything. The one-line separation to carry into the exam room: a check warns; a condition stops. Health-shaped questions belong in checks; invariants belong in conditions. Across all four surfaces: validations for per-input sanity, preconditions for assumptions, postconditions for guarantees, checks for observations that should never block a deploy.
§ IVGate Four — terraform test (Pro Depth)
The native harness landed in Terraform 1.6, which places it past the 2022 edition of the tome this arc grounds in; the vault carries no book on it yet, and the mechanics below come from the current HashiCorp surface. The Pro exam assumes them cold.
Test files end in .tftest.hcl, live in tests/ beside the module, and are executed by terraform test. A file holds variables blocks for inputs and a sequence of run blocks, executed in order. Each run performs a real operation, command = apply by default or command = plan for the cheap variant, then evaluates its assert blocks: condition plus error_message, the same grammar as gates two and three, aimed at the run's resolved values.
variables {
project_id = "hedronite-sandbox"
env = "dev"
name_suffix = "tftest"
}
run "bucket_is_named_for_env" {
command = plan
assert {
condition = google_storage_bucket.site.name == "hedronite-site-dev"
error_message = "bucket name must carry the env."
}
}
run "site_applies_clean" {
assert {
condition = output.site_url != ""
error_message = "apply must produce a site_url."
}
}
State handling is the detail that separates this harness from everything earlier in the lesson. Each test file runs against ephemeral state that exists only for the duration of the file; whatever the apply runs created, the harness destroys automatically when the file finishes, in reverse run order. Ephemeral state, automatic reverse-order demolition: today's Ops lesson called that class of thing the disposable environment, and the Dev lesson built it by hand in Go with a defer. The native harness is the same rental contract with the signature pre-printed.
Pro-depth extensions to hold: providers blocks inside test files swap real providers for configured ones; override_resource and override_data (1.7+) fake a resource or data source so a unit-shaped test never touches the cloud; mock_provider generates synthetic values wholesale. The allocation logic mirrors the Dev lesson's split: command = plan with overrides is the fast blind gate, command = apply against a sandbox project is the slow honest one, and a Pro-grade module ships with both plus a CI job that runs terraform test before any version tag the promotion boundary would pick up.
§ VCross-Walk — terraform test vs terratest
The Pro exam and real teams both ask when the native harness suffices and when to reach for terratest. The native harness speaks HCL, needs no second language, manages its own ephemeral state, and asserts on plans, applies, outputs, and mocked values. Terratest speaks Go, brings the whole language with it, and asserts on anything Go can reach: an HTTP GET against the deployed site, an SDK read of bucket metadata, a retry policy tuned per error class. The boundary falls at the edge of Terraform's own knowledge. Everything the config can express, the native harness can test more cheaply; anything that requires standing outside the config and interrogating the world through another door wants terratest.
§ VIConnection to Today's Ops and Dev Lessons
The trio is one argument at three altitudes. The Ops lesson built the durable environments and the promotion boundary between them; nothing crosses that boundary except a version pin. The Dev lesson built the strong gate a version must pass in Go, renting real infrastructure. This lesson names the full gate ladder the exams test: validate for the text, validations and conditions for the contract, checks for the watchful warnings, terraform test for the proof, terratest past the edge of Terraform's sight. The disposable environment appears in all three lessons wearing three uniforms: a workspace, a Go test's unique-suffix rental, a test file's ephemeral state.
§ VIIPractice Questions
terraform validate passes on a config declaring machine_type = "e2-enormous-96", a type that does not exist. Why, and which gate catches it?var.env == "prod" to imply var.deletion_protection == true. An engineer writes a validation block inside variable "env" referencing both variables. What happens?self.uniform_bucket_level_access fails during apply, after the bucket was created. The run's outcome and the bucket's fate?check block asserting the site returns HTTP 200 fails during apply. What does Terraform do?.tftest.hcl file contains three run blocks with the default command. Where does their state live after terraform test exits?§ VIIIQuestion Resolutions
Answer 1. Validate checks the config against the provider's schema, offline. machine_type is a legal string argument, so schema-wise the config is sound; no API call ever asks GCP whether the type exists. The failure surfaces at apply, when the world answers. Plan does not reliably catch it either, since plan predicts rather than commits. The gate ladder: validate proves the text, plan proves the graph, apply proves the world.
Answer 2. The run errors at parse: a validation block's condition may reference only its own variable. The rule belongs in a precondition, on the resource that depends on it (or on a terraform_data resource dedicated to the check), where any variable or data source is in scope: condition = var.env != "prod" || var.deletion_protection.
Answer 3. The apply halts with the postcondition's error message. The bucket exists; Terraform does not roll it back, and it is recorded in state. The halt is the contract: the guarantee to downstream consumers failed, so the run refuses to continue to resources that would have inherited the broken guarantee. The operator fixes the config and re-applies.
Answer 4. The apply completes and exits successfully, with the check's failure reported as a warning. Checks observe; they never gate. Anything that must gate belongs in a condition. A check warns; a condition stops.
Answer 5. Nowhere. The harness ran the three runs in order against ephemeral state scoped to the file, then destroyed everything created, in reverse run order, as the file finished. Nothing persists to any backend; a test failure still triggers the destruction sweep, with any stragglers reported for manual cleanup.
Answer 6. Quota exhaustion and IAM denial (name collision and eventual consistency also accepted). Plan asks the provider to predict a change from state and schema; it does not reserve capacity, exercise permissions, or claim names. Only apply asks the world to commit, so every answer the world alone can give arrives at apply. Valid plans can fail; the plan is a forecast, and the apply is the weather.
Filed 2026-07-31 Fajr · Cert lesson · TF deep-mastery track (day 9, visit 4) · Pro-depth weighting