Validation & Testing Overview
Infrastructure code deserves the same quality discipline as application code, but the techniques are layered differently. A single terraform apply can create or destroy production resources, so the goal is to catch every class of problem — bad style, broken syntax, anti-patterns, insecure defaults, and incorrect behavior — before anything reaches the cloud. Terraform’s ecosystem gives you a ladder of checks, each faster and cheaper than the one above it, that you run in order so failures surface as early as possible. This page maps that ladder and shows where each rung fits in your local workflow and CI pipeline. Everything here applies equally to OpenTofu.
The quality ladder
Think of validation as a pipeline of increasingly expensive checks. The cheapest run offline in milliseconds; the most expensive provision real infrastructure. You order them so a trivial formatting mistake never wastes time waiting on a security scan or an integration test.
| Layer | Tool | What it catches | Network / cloud? |
|---|---|---|---|
| Format | terraform fmt | Inconsistent style and indentation | None |
| Validate | terraform validate | Syntax errors, bad references, type mismatches | None (needs init) |
| Lint | tflint | Anti-patterns, deprecated syntax, invalid instance types | Optional (provider rules) |
| Security scan | checkov, tfsec, trivy | Insecure configurations and misconfigurations | None |
| Policy | OPA / Sentinel / Conftest | Org-specific rules and guardrails | None |
| Unit / contract | terraform test | Module behavior against plan and apply | Sometimes |
| Integration | Terratest | Real deployed resources behave correctly | Yes (provisions real infra) |
Format and validate
The first two rungs are built into Terraform itself and cost nothing to run. terraform fmt rewrites files into canonical HCL so every diff is minimal and reviews stay focused on substance. terraform validate parses the configuration and confirms it is internally consistent — references resolve, types line up, required arguments exist — without making a single API call.
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
Output:
Success! The configuration is valid.
These run in seconds and require no credentials, so they belong at the very front of CI and in a pre-commit hook. See terraform validate for the full command reference.
Linting
validate confirms a configuration is well-formed, but it cannot tell you whether it is sensible. That is the job of a linter. TFLint catches deprecated syntax, unused declarations, and — with its provider plugins — cloud-specific mistakes such as an EC2 instance type that does not exist in the chosen region.
tflint --init
tflint --recursive
Output:
2 issue(s) found:
Warning: instance type "t2.mikro" is invalid (aws_instance_invalid_type)
on main.tf line 8:
8: instance_type = "t2.mikro"
Linting still makes no changes to infrastructure, so it remains a fast, safe early gate.
Security and policy scanning
Even a valid, idiomatic configuration can be dangerously insecure — a publicly readable S3 bucket, an unencrypted volume, an open 0.0.0.0/0 security group. Static security scanners read your HCL and flag these patterns against a built-in rule library. Checkov and tfsec are the most widely used.
checkov -d . --quiet --compact
Output:
Passed checks: 14, Failed checks: 1, Skipped checks: 0
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.data
File: /main.tf:1-4
Where security scanners enforce general best practices, policy as code enforces your organization’s rules — for example, “every resource must carry a CostCenter tag” or “no resources outside eu-west-1.” Tools like Open Policy Agent (Conftest), HashiCorp Sentinel, and Terraform Cloud run policies against the plan JSON, so they can block an apply that would violate a guardrail.
Run security and policy checks against the saved plan output (
terraform show -json plan.tfplan) as well as the raw HCL. Scanning the plan catches issues that only become concrete after variables and module inputs are resolved.
Testing behavior
The top rungs verify that your modules actually do the right thing. The native terraform test framework (stable since Terraform 1.6) lets you write assertions in .tftest.hcl files that run against a plan or a real apply and tear down afterward.
run "bucket_is_private" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_acls == true
error_message = "S3 bucket must block public ACLs"
}
}
terraform test
Output:
tests/storage.tftest.hcl... in progress
run "bucket_is_private"... pass
tests/storage.tftest.hcl... teardown complete
Success! 1 passed, 0 failed.
For deeper integration testing — deploying a module, then making real HTTP or SDK calls to confirm the resources behave — Terratest drives Terraform from Go. It is slower and provisions billable infrastructure, so it runs last, typically against an ephemeral environment.
Where each layer fits in CI
A typical pipeline runs the rungs in cost order and fails fast. The first three stages are offline and need no cloud credentials; only the integration stage touches real infrastructure.
terraform fmt -check -recursive # style
terraform validate # syntax + consistency
tflint --recursive # lint
checkov -d . # security scan
terraform test # behavior
The earlier a check sits, the more often it should run: format and validate on every save, lint and scan on every push, native tests on every pull request, and Terratest on a schedule or before release. This ordering keeps feedback tight and cloud spend low.
Best Practices
- Run the cheapest checks first so a malformed file fails in seconds, not minutes.
- Make
fmt -checkandvalidatenon-negotiable gates in both pre-commit and CI. - Add a security scanner (Checkov or tfsec) early — insecure defaults are the most common production incident.
- Scan the resolved plan JSON, not just raw HCL, so dynamically computed values are covered.
- Use
terraform testfor fast module-level assertions and reserve Terratest for full integration paths. - Pin tool versions (TFLint, Checkov, providers) so CI results are reproducible across machines.
- Treat policy as code as a guardrail, not a gatekeeper — keep policies few, clear, and well-documented.