Testing and Policy as Code for Terraform
Infrastructure needs tests too. Climb the pyramid from fmt/validate/tflint and security scanners to native terraform test and Terratest, then enforce guardrails with policy as code on the plan JSON.
🎯 What you'll learn
- The infrastructure testing pyramid
- Static checks: fmt, validate, tflint, checkov/trivy
- Native terraform test and Terratest end-to-end tests
- Policy as code (OPA/conftest, Sentinel) on the plan JSON
📚 Before you start
1The Testing Pyramid for Infrastructure
┌────────────────────────────────────────────┐
│ end-to-end: deploy a real stack, probe it │ terratest / terraform test (apply), slow, costs money
├────────────────────────────────────────────┤
│ plan-level tests & policy on plan JSON │ terraform test (plan), OPA/Conftest, Sentinel
├────────────────────────────────────────────┤
│ static analysis: fmt, validate, lint, scan │ seconds, run on every commit
└────────────────────────────────────────────┘2Static Checks (Every Commit)
| Tool | Catches |
|---|---|
terraform fmt -check | formatting drift |
terraform validate | syntax, type errors, unknown arguments |
| TFLint (+ AWS ruleset) | invalid instance types, deprecated syntax, unused variables, naming conventions |
| Checkov / Trivy (tfsec) | security misconfigurations: public buckets, unencrypted volumes, 0.0.0.0/0 on port 22, IAM *, missing logging |
| terraform-docs | keeps module READMEs in sync |
tflint --init && tflint --recursive
checkov -d infra/ --framework terraform --compact
trivy config infra/Findings can be suppressed with an inline, justified comment (#checkov:skip=CKV_AWS_18:access logs not needed for this bucket) — reviewable, unlike global ignores.
3Native terraform test (Terraform ≥ 1.6)
Tests live in *.tftest.hcl files next to a module:
# tests/secure_bucket.tftest.hcl
variables {
name = "unit-test-bucket"
}
run "blocks_public_access" {
command = plan # fast: no real resources
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_acls == true
error_message = "public ACLs must be blocked"
}
}
run "rejects_bad_names" {
command = plan
variables { name = "UPPER_case" }
expect_failures = [var.name] # the validation block must fail
}
run "creates_real_bucket" {
command = apply # real resources, destroyed after the test file
assert {
condition = startswith(output.arn, "arn:aws:s3:::unit-test-bucket")
error_message = "unexpected ARN"
}
}mock_provider blocks (≥ 1.7) let plan/apply tests run without cloud credentials by faking provider responses.
# tests/secure_bucket.tftest.hcl
run "blocks_public_access" {
command = plan
variables { name = "petpal-test-uploads" }
assert {
condition = aws_s3_bucket_public_access_block.this.restrict_public_buckets
error_message = "bucket must restrict public access"
}
}command = plan runs in seconds with no real resources; use apply runs (in a sandbox account) for behaviour that needs real infrastructure.
4Terratest (Go) for End-to-End
Terratest applies a module in a real account, runs assertions against live infrastructure (HTTP requests to the ALB, AWS API calls), then destroys it:
func TestService(t *testing.T) {
opts := &terraform.Options{TerraformDir: "../examples/basic", Vars: map[string]interface{}{"name": "tt-" + random.UniqueId()}}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
url := terraform.Output(t, opts, "url")
http_helper.HttpGetWithRetry(t, url+"/healthz", nil, 200, "ok", 30, 5*time.Second)
}Use a dedicated sandbox account, unique names, and a nuke job for leftovers.
5Policy as Code on the Plan
Some rules are organizational, not module-specific: "no public S3 buckets", "every resource tagged with owner", "no instance types larger than 4xlarge in dev", "no deletion of RDS instances without approval". Evaluate them against the plan JSON:
terraform plan -out=tfplan && terraform show -json tfplan > plan.json
conftest test plan.json -p policy/package terraform
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_s3_bucket_public_access_block"
rc.change.after.block_public_acls == false
msg := sprintf("%s must block public ACLs", [rc.address])
}
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_db_instance"
rc.change.actions[_] == "delete"
msg := sprintf("deleting database %s requires an approved exception", [rc.address])
}HCP Terraform uses Sentinel or OPA policies with advisory/soft/hard enforcement levels. Policies run in CI after the plan and before approval (07-terraform_in_ci_cd/).
Under the HoodThe Plan JSON Format▾
terraform show -json tfplan emits a stable, documented JSON structure: planned_values (the full resulting state), resource_changes (one entry per resource instance with address, type, change.actions — e.g. ["no-op"], ["create"], ["update"], ["delete","create"] for replacements — plus change.before / change.after attribute maps and after_unknown for values known only after apply), configuration (the parsed config, including module calls and expressions), and prior_state. Because it's plain JSON, any engine — OPA, a Python script, a custom GitHub check — can reason about exactly what will change, which is far more reliable than grepping HCL (variables, modules and for_each make source code hard to analyze statically).
⚠️ Common Pitfalls
- Only
validate— syntactically valid infrastructure can still be insecure or wrong. - Global suppressions of scanner rules instead of justified inline skips.
- E2E tests in shared accounts without cleanup — cost and collisions.
- Policies that grep HCL instead of evaluating the plan.
- Tests that never run in CI.
📋 Quick Reference
every commit: fmt -check · validate · tflint · checkov/trivy · terraform-docs
module tests: *.tftest.hcl (run { command = plan|apply, assert, expect_failures }, mock_provider)
E2E: terratest in a sandbox account, unique names, defer destroy
policy: terraform show -json tfplan → conftest/OPA or Sentinel (deny public, require tags, protect deletes)🧠 Key takeaways
- Test bottom-up: static checks on every commit, unit tests with
terraform test, a few end-to-end Terratest runs. - Security scanners (checkov, trivy, tfsec) catch common misconfigurations early.
- Policy as code evaluates the plan JSON (OPA/conftest, Sentinel) and blocks non-compliant changes before apply.
- Keep tests fast and run E2E tests in isolated sandbox accounts with cleanup.
🎤 Interview Q&A
Q1How do you test Terraform code?core▾
Static checks (fmt, validate, tflint), security scanners (Checkov, tfsec/Trivy), terraform test for unit-like assertions on plans, and Terratest or real applies in sandbox accounts for integration tests.
- What do plan-based tests catch? Wrong values, counts and missing configuration before any apply.
Q2What is policy as code?core▾
Expressing organizational rules (no public S3, required encryption, allowed instance types, mandatory tags) as code evaluated automatically: OPA/Conftest (Rego), Sentinel (Terraform Cloud), Checkov custom policies.
- Where do you run it? Against plan JSON in CI before apply.
Q3Give examples of useful policies.senior▾
Deny public S3 buckets and 0.0.0.0/0 on non-web ports, require encryption and backups for databases, restrict regions and instance types, require cost/owner tags, and limit expensive resources per environment.
- Advisory vs mandatory? Start advisory to tune, then enforce.
Q4How do you test without real cloud costs?core▾
Plan-only tests, mocks in terraform test (mock providers), LocalStack for some AWS services, and small ephemeral sandbox applies with automatic destroy.
- LocalStack limits? Not every service or behaviour matches AWS exactly.
Q5How do policy checks fit with cloud-native guardrails?core▾
Policy as code catches issues before apply; cloud guardrails (SCPs, AWS Config) catch changes made outside Terraform. Use both layers.
- Why both? Not every change goes through Terraform.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.