Terraform Operations #3 Code Quality and Testing: tflint, pre-commit, and terraform test
In the PR workflow from #1, what the human reviewer looked at was the code and the plan. But reviewer time is the most expensive resource on a team, and spending it on problems a machine could catch is a waste. This part is about stacking automated checks, layer by layer, in front of human review. We start from fmt and validate, which you already know, pass through tflint, and climb up to terraform test, which verifies what a module actually does.
The check layers: what each tool catches #
The four layers of checks catch different things.
| Tool | What it catches | Example |
|---|---|---|
| terraform fmt | Formatting | Indentation, alignment |
| terraform validate | Syntax and references | Misspelled arguments, undefined variables |
| tflint | Provider-knowledge errors and conventions | Instance types that do not exist |
| terraform test | Module behavior | “This input must produce this result” |
The difference between validate and tflint is the key point. instance_type = "t3.mcro" (a typo) passes validate, because as HCL it is a perfectly fine string. This typo would only surface when apply gets an error back from AWS — but tflint, using its AWS provider ruleset, catches value-level errors like this before plan. It also flags cleanup items such as declared-but-unused variables and naming convention violations.
# .tflint.hcl
plugin "aws" {
enabled = true
version = "0.44.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}pre-commit: automation at commit time #
If these checks only run in CI, feedback is slow (push, wait a few minutes, discover the failure). Run them locally at commit time with a pre-commit hook and they catch problems in seconds.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.99.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflintpre-commit-terraform is the de facto standard repository that bundles this combination. The one thing to keep in mind: the CI checks must stay in place. Hooks are personal environment setup, and some people may not have them installed — local hooks are for fast feedback, and CI remains the final gate.
terraform test: verifying what a module does #
Everything from fmt through tflint looks at the surface of the code. Verifying behavior — “given this input, does this module produce the intended result?” — is the job of terraform test, built into Terraform since 1.6. In Practice #9, modules became the team’s shared assets, which created the need for a guarantee that changing a module will not break its existing consumers — and that is precisely the moment tests become necessary.
Tests are written as .tftest.hcl files in a tests/ directory. Let’s verify the static-bucket module from Basics #8.
# modules/static-bucket/tests/naming.tftest.hcl
run "bucket_name_is_applied" {
command = plan
variables {
bucket_name = "test-bucket-name"
}
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket-name"
error_message = "The name passed to the bucket argument must be applied as-is."
}
}
run "versioning_is_enabled" {
command = plan
variables {
bucket_name = "test-bucket-name"
}
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Buckets from this module must always have versioning enabled."
}
}$ terraform -chdir=modules/static-bucket test
tests/naming.tftest.hcl... pass
run "bucket_name_is_applied"... pass
run "versioning_is_enabled"... passThe structure is simple. One run block is one test case: variables provide the input, and the assert’s condition must be true to pass. The syntax used in condition is exactly the expression syntax you learned in Basics #4.
The cost of plan tests versus apply tests #
It matters that command = plan is a deliberate choice, not the default. The default command for a run block is apply: it creates real resources and destroys them after the test. You get verification against real AWS behavior, but it is slow, it costs money, and cleanup can fail. A practical rule looks like this.
- Default to plan tests: verifying the relationship between inputs and planned results is fully covered by plan, and since it is fast and free, it fits into pre-commit and CI without friction.
- Use apply tests selectively, for critical modules: reserve them for shared modules where you need to know things are “really created,” and in CI run them in a separate workflow (nightly, for example) so PR feedback stays fast.
Once all of these checks (including the plan from #1) run automatically on every PR, the PRs that reach a human reviewer arrive with formatting, syntax, values, and behavior already verified. The reviewer can finally spend their time on the questions only a human can answer: is this design right, is this replacement intentional?
Recap #
What we covered in this post:
- Checks come in four layers: fmt (formatting), validate (syntax), tflint (provider knowledge), and terraform test (behavior)
- validate cannot catch an instance type that does not exist. Value-level errors are caught before plan by tflint’s AWS ruleset
- pre-commit hooks are for fast local feedback; the final gate is CI
- terraform test verifies module behavior with tftest files built from run, variables, and assert. The moment a module becomes a shared asset is the moment to introduce tests
- The default command for run is apply (real resources, real cost), so default to plan tests and apply apply tests selectively to critical modules
In the next post (#4 Security and Cost Scanning), we add two more axes of automated checks: trivy and checkov, which catch security misconfigurations, and Infracost, which shows cost changes right on the PR.