The Cost Ladder: Which Checks Run on Every Commit and What Waits
A pull request touches one module in a repository of sixty. How much of your pipeline should run on it? If the answer is "everything," median feedback stretches past twenty minutes and reviewers stop waiting for the plan to appear. If the answer is "fmt and hope," you learn about a broken count expression when an apply fails in staging overnight. The workable answer is a cost ladder: order every check by what it costs to run, run the cheap ones everywhere, and ration the expensive ones.
The previous topic built a harness that applies real infrastructure, asserts on it, and destroys it — Terratest Harness in Go. The question now is where that expensive run belongs. The test pyramid for infrastructure inverts the usual intuition about what a "unit" is: there is no pure function to call. Your unit is a plan, because a plan exercises the dependency graph, expressions, variable validations, and the provider schema without touching a cloud API.
| Layer | Typical commands | Duration | Cloud spend | What it catches | When it runs |
|---|---|---|---|---|---|
| Static | fmt -check, validate, tflint, trivy config |
10–30 s | $0 | formatting, syntax, undeclared variables, hardcoded secrets | every push |
| Plan unit | terraform test with mock_provider |
30–90 s | $0 | wiring, count guards, validation rules, expected failures |
every pull request |
| Plan review | plan -detailed-exitcode, Infracost, OPA on plan JSON |
30–120 s | ≈$0 | drift against main, cost delta, policy violations | every pull request |
| Integration | Terratest: apply → assert → destroy | 8–25 min | real money: $2–$20 a run | provider behavior, IAM propagation, quota limits | merge and nightly |
Two numbers in that table drive the entire design.
Static checks are free, so they run on every commit with no exceptions. fmt -check -recursive plus init -backend=false && validate finish in under thirty seconds and need no credentials, no backend, and no state. Teams that gate only on plans still spend minutes of runner time before discovering a typo in a variable reference.
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate
tflint --recursive
Integration runs are the only layer with real cost, so they get a budget instead of a habit. A suite that creates a VPC, a database, and a load balancer runs about fifteen minutes; the infrastructure costs a few dollars per execution and the runner adds minutes on top. Six parallel jobs nightly is roughly ninety hosted-runner minutes a day — around forty-five dollars a month before cloud spend. Multiply that by five environments and the pipeline itself becomes a line item someone will challenge.
The rule that keeps this honest: every check that does not need cloud credentials runs on every commit; everything that provisions runs on merges and schedules. Credential-free checks are also the only ones that safely run on pull requests from forks, where secrets are withheld — which is exactly why the plan-unit layer is your external contribution gate.
Design against two failure modes. The first is a wall: ten required checks at four minutes each, serialized, so nobody merges before lunch. Keep total pull-request feedback under five minutes by running layers in parallel and testing only directories the diff touched — a paths filter or a changed-directory matrix cuts a monorepo run from sixty modules to three. The second is a check nobody reads: a nightly job that has been red for a month is decoration. Alert when a gate fails, and treat a broken gate as a bug in the pipeline itself, with the same urgency as a failing service.
Feedback cost is a design input, not an accident. The next chapter takes the middle of the ladder apart: gating a pull request with plan tests that provision nothing.
Gating the Pull Request with Credential-Free Plan Tests
A plan against a real account is the most informative check you have, and it is the wrong gate for a pull request. It needs credentials, forked pull requests never receive secrets, and a plan against a live account reflects drift and pre-existing resources — not just your diff. The gate instead is terraform test running in plan mode against mock providers: it validates the wiring of the module under test in about a minute with no cloud access at all.
name: ci
on:
pull_request:
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: terraform fmt -check -recursive
- run: terraform init -backend=false -input=false
- run: terraform validate
unit-plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init -backend=false -input=false
- run: terraform test
terraform test discovers every .tftest.hcl file beside the configuration. With command = plan and a mock_provider block, the run builds the full graph and evaluates every expression against fabricated computed attributes — no credentials, no API calls, no cost. Assertions on planned values catch the mistakes reviewers miss: a count that evaluates to zero, a tag that never propagates, a validation rule that silently does not fire on the path you changed.
The credential-free property is the part that matters most. pull_request workflows from forks run without secrets; any job requiring AWS_ROLE_ARN either fails or tempts someone into pasting keys into the workflow file. Plan-unit tests never ask for secrets, so the identical gate serves internal and external contributors.
What belongs in this layer? Anything a reviewer would otherwise verify by squinting at a diff:
run "rejects_invalid_name" {
command = plan
variables {
name = "Invalid Name!"
}
expect_failures = [var.name]
}
run "creates_bucket_when_enabled" {
command = plan
assert {
condition = length(aws_s3_bucket.this) == 1
error_message = "versioning must produce exactly one bucket"
}
}
These two assertions took seconds to write and they fail deterministically on every pull request that breaks them — the same guarantee you get from a unit test in application code.
Add two more jobs to the same pull request — a plan review and a policy check:
terraform plan -input=false -out=tfplan -detailed-exitcode
terraform show -json tfplan > plan.json
infracost breakdown --path plan.json --format table
conftest test -p policy/ plan.json
-detailed-exitcode returns 0 for "no changes" and 2 for "changes present." A naive CI step treats 2 as failure and blocks every pull request that legitimately adds a resource. Map it explicitly: exit 0 and 2 pass, anything else fails.
Now the merge rule. Require status checks by exact job name — static, unit-plan, policy, plan / staging — so a red or missing job blocks the merge. Two sharp edges bite teams here. Renaming a job leaves the old context in branch protection, and the required check then sits pending forever until someone edits the settings; treat job names as a published contract. And a check that ran against an outdated commit proves nothing about the current tip: enable "require branches to be up to date" (strict: true) or a merge queue that re-runs the plan against the merged result before landing.
Cache provider binaries with TF_PLUGIN_CACHE_DIR rather than caching the whole .terraform directory — a stale .terraform snapshot can resurrect state metadata from a previous run, while the plugin cache is content-addressed against the committed .terraform.lock.hcl.
Every layer so far provably works without a cloud account. What planning cannot prove — that the provider actually accepts your arguments, that the resource reaches the state you asserted, that quotas allow four more load balancers — is the subject of the final layer: scheduled applies in a sandbox.
Nightly Applies and Turning Every Incident Into a Test
A plan proves your configuration is internally consistent. It cannot prove that AWS accepts kms_key_id on that resource, that IAM policies propagate fast enough for your assertion, or that the account has quota left for another load balancer. Those questions need real applies — which is why integration tests belong on merges to main and on a scheduled nightly run, never on every pull request.
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
integration:
strategy:
fail-fast: false
matrix:
module: [network, database, compute, ingress]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go test -timeout 45m ./...
env:
AWS_REGION: us-east-1
Three properties of that job are deliberate. fail-fast: false keeps the other modules running when one fails, so a single broken suite does not hide three passing ones. The matrix isolates blast radius: each run creates its own resources under a unique name suffix, so two modules apply in parallel without colliding on a bucket name. The 45-minute timeout bounds cost — a stuck Destroy cannot bill you all night.
Budget the top of the ladder explicitly. Nightly applies run in a dedicated sandbox account with a hard budget alert and a janitor: a scheduled job that deletes everything older than 24 hours that no active run claims. Failed destroys are the most common source of surprise infrastructure bills. defer destroy() in Terratest runs even when an assertion fails, but a killed runner skips it entirely — only the janitor catches that case.
Decide what is allowed to live at the top of the ladder, because everything here is expensive. Keep only tests that verify behavior no plan can observe: IAM policy attachment actually taking effect, a security group rule reachable from outside, a database accepting connections with the generated credentials, a provider rejecting an invalid argument combination. Logic that merely decides what gets created — counts, tags, name formatting, validation rules — belongs in the plan-unit layer where it runs for free on every pull request. When a nightly failure turns out to be a wiring bug, that is a smell: the test was parked one rung too high.
Flakiness needs a policy, not patience. A test that fails intermittently gets quarantined: tagged in the matrix, excluded from the required set, linked to an issue with a last-checked date. Quarantine is a loan. Anything older than two weeks is either fixed or deleted, because a permanently red test teaches everyone to ignore red — and a suite everyone ignores is worse than no suite, since it still burns runner money.
The final rule is the one interviewers remember: promote every recurring production fix into a test in the same pull request that fixes it. Coverage gaps do not announce themselves on a dashboard; they surface as incidents. If a security group stood open to 0.0.0.0/0 for three days because no test asserted its CIDR, the fix pull request carries both the corrected .tf and a .tftest.hcl case that fails the moment anyone widens the rule again. Add a policy rule on the plan JSON too, so the standard holds for modules nobody remembered to test.
Track coverage the only way that means something for infrastructure: the percentage of modules with at least one test file, and the count of assertions per module. A module with a test file and zero assertions is a test that never fails. Report both numbers monthly; a falling module count is an early warning that the platform is growing faster than its safety net.
That completes the ladder — static, plan-unit, plan-review, integration — each running where its cost is justified. The next topic applies the same layering instinct to changes that never come through your pipeline at all: Detecting Configuration Drift.
Quiz
1. Your monorepo holds 60 modules and a pull request touches one. Which pipeline design keeps feedback fast without weakening the gate?
2. A CI step runs `terraform plan -input=false -detailed-exitcode` and fails on a pull request that legitimately adds two resources. What is wrong?
3. The nightly integration job costs about 45 dollars a month in runner time plus cloud spend. Which change cuts cost while preserving coverage?
4. A security group stood open to 0.0.0.0/0 for three days because no test asserted its CIDR. What is the systemic fix?
Flashcards
Question
Which Terraform test layers run with no cloud credentials, and where does each belong?
Click to reveal answer
Answer
Formatting, validate, lint and security scans, plus plan-based terraform test runs with mock providers — all credential-free. They belong on every push and every pull request. Only apply-based integration tests need credentials, so they run on merges to main and on scheduled nightly jobs.
Question
What does terraform plan -detailed-exitcode return, and how must CI treat the codes?
Click to reveal answer
Answer
0 means no changes, 2 means changes are present, 1 means an error. CI must treat 0 and 2 as success and only 1 as failure — otherwise every pull request that legitimately adds a resource blocks on a red check.
Question
Why are integration tests scheduled instead of required on every pull request?
Click to reveal answer
Answer
They provision real resources: 8-25 minutes of runner time and a few dollars of cloud spend per run, multiplied by every push, and they cannot run on forked pull requests, which receive no secrets. They belong on merges and nightly in a sandbox account with a budget cap.
Question
What two properties make an unattended nightly Terratest matrix safe?
Click to reveal answer
Answer
fail-fast disabled with per-module isolation (unique resource name suffixes) so one failure neither hides the others nor collides with them, and a hard timeout plus a janitor deleting unclaimed sandbox resources older than 24 hours, which bounds cost when a killed runner skips destroy.
Question
How do you measure test coverage for infrastructure code?
Click to reveal answer
Answer
The percentage of modules with at least one test file, and the number of assertions per module. A module with a test file and zero assertions never fails, so assertion count is the honest signal; report both monthly and treat a falling module count as an early warning.
Revision Notes
Key Takeaways
- 1. Static checks are free and credential-free — run them on every commit, and they are the only layer that also passes on forked pull requests where secrets are withheld
- 2. Plan-unit tests with mock providers are the pull-request gate: full graph, real assertions, about a minute, zero cloud cost
- 3. -detailed-exitcode returns 2 for changes present — map 0 and 2 to pass, or every legitimate change blocks on a red check
- 4. Applies run only on merges and nightly, with fail-fast off, isolated name suffixes, bounded timeouts, and a janitor capping spend
- 5. Coverage grows the only way that sticks: promote each production fix into an assertion plus a plan-JSON policy rule, and track tested modules monthly
Interview Tips
- • "Walk me through your Terraform CI" — name the four layers with cost and cadence for each: static on every push, plan-unit and plan-review on the PR, integration on merge and nightly; interviewers are checking that you place each check where its cost is justified
- • "How do you test a change from an external contributor?" — only credential-free layers survive: fmt, validate, and mock-backed terraform test; mention that plans against live accounts need secrets forks never receive
- • "What did your nightly pipeline cost and how did you control it?" — give numbers: sandbox account, dollar budget, fail-fast off, 45-minute timeout, janitor, quarantine policy with a two-week expiry
- • "A bug reached production through Terraform — what now?" — the promote-to-test rule: fix PR carries both the corrected resource and the assertion plus policy rule that prevents recurrence; cite the specific assertion you would add
Cheat Sheet
Layering Tests in the Pipeline
The cost ladder (run cheap checks everywhere):
| Layer | Command | Duration | Spend | Runs |
|---|---|---|---|---|
| Static | fmt -check, init -backend=false, validate, tflint |
10-30 s | $0 | every push |
| Plan unit | terraform test with mock_provider |
30-90 s | $0 | every PR |
| Plan review | plan -detailed-exitcode, Infracost, conftest on plan JSON |
30-120 s | ~$0 | every PR |
| Integration | Terratest apply → assert → destroy | 8-25 min | $2-$20/run | merge + nightly |
Pipeline rules:
# credential-free PR gate
terraform fmt -check -recursive && terraform init -backend=false && terraform validate
terraform test
# plan review — 0 and 2 both pass
terraform plan -input=false -out=tfplan -detailed-exitcode
terraform show -json tfplan > plan.json
conftest test -p policy/ plan.json
Nightly job: fail-fast: false, unique name suffixes per run, -timeout 45m, sandbox account with a budget alert, janitor deleting unclaimed resources >24h.
Regression rule: every recurring production fix ships with a .tftest.hcl assertion AND a policy rule in the same PR. Coverage = modules with tests / total modules; assertion count per module is the real metric.
Feedback budget: total PR checks under ~5 minutes; filter to changed directories in a monorepo; alert on a red gate.