Engineering Notes
Home/DevOps & Cloud/Terraform/Testing and Policy as Code for Terraform
DevOps & Cloud · Terraform

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.

☁️ DevOps & Cloud● Advanced⏱ 4 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

text
            ┌────────────────────────────────────────────┐
            │  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)

ToolCatches
terraform fmt -checkformatting drift
terraform validatesyntax, 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-docskeeps module READMEs in sync
bash
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:

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

💡 Example: a fast plan-only test
hcl
# 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:

go
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:

bash
terraform plan -out=tfplan && terraform show -json tfplan > plan.json
conftest test plan.json -p policy/
rego
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).

Static checks run on every commit, terraform test runs plan and apply tests with optional mocked providers, Terratest deploys and probes real stacks, and OPA or Sentinel policies evaluate the plan JSON before approval
Static checks run on every commit, terraform test runs plan and apply tests with optional mocked providers, Terratest deploys and probes real stacks, and OPA or Sentinel policies evaluate the plan JSON before approval

⚠️ Common Pitfalls

  1. Only validate — syntactically valid infrastructure can still be insecure or wrong.
  2. Global suppressions of scanner rules instead of justified inline skips.
  3. E2E tests in shared accounts without cleanup — cost and collisions.
  4. Policies that grep HCL instead of evaluating the plan.
  5. Tests that never run in CI.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why run policies on the plan JSON instead of the .tf files?
You check what will actually be created.
2. Which runs in seconds without creating resources?
Plan-only assertions.
3. Where should end-to-end infrastructure tests run?
They create real resources.
4. checkov/trivy are examples of…
Run them on every commit.