How to Test Terraform: terraform test, TFLint and Checkov (2026)
This guide is for anyone who maintains Terraform modules and wants changes checked by a machine before a human reads them, which matters more now that a lot of Terraform arrives from AI assistants (my companion post on AI-generated Terraform covers why). By the end you will have a module with fast unit tests that need no cloud credentials, an integration test that creates and destroys real resources, TFLint, Checkov and Trivy scans, a pre-commit setup and a GitHub Actions pipeline. I tested every command on 20 October 2026 with Terraform 1.16.6, AWS provider 6.69.1, TFLint 0.64.0 with the AWS ruleset 0.49.0, Checkov 3.3.29, Trivy 0.76.0, Terratest 2.0.0 and Go 1.26. You need Terraform 1.7 or later (mocks arrived in 1.7.0), and an AWS account only for the integration step.
Step 1: Pick the right test for each question
Terraform testing is not one tool. Each layer answers a different question, and the cheap ones should fail first so you never wait for an apply to learn you misspelt an argument.
The example is a small S3 bucket module: a bucket, versioning that is only enabled in prod, and a public access block. Here is the layout we will end up with:
s3-bucket/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── .tflint.hcl
├── .pre-commit-config.yaml
└── tests/
├── unit/s3.tftest.hcl # mocked, no credentials
└── integration/s3.tftest.hcl # real AWS, creates and destroys
Step 2: Write a module with validation built in
Input validation is the first test, and it runs on every plan, forever. Put the rules in variables.tf:
# variables.tf
variable "bucket_name" {
description = "Globally unique S3 bucket name."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "bucket_name must be 3-63 lowercase letters, digits, dots or hyphens."
}
}
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
# versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.environment == "prod" ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# outputs.tf
output "bucket_arn" {
description = "ARN of the bucket."
value = aws_s3_bucket.this.arn
}
Then run the two free checks:
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate
Expected output: Success! The configuration is valid. Know what validate does not cover. When I tested a bucket with an invented encryption = "AES256" argument, it failed with An argument named "encryption" is not expected here. A deprecated inline versioning {} block passed with a warning and exit code 0. Validation catches invention, not obsolescence or insecurity.
Step 3: Write unit tests with terraform test and mock providers
terraform test (GA since 1.6) reads *.tftest.hcl files from the root module and the tests directory. Each run block executes a plan or apply and then evaluates assert blocks. A mock_provider block replaces the real provider, so nothing calls AWS and no credentials are needed. Mocks only generate computed attributes; you still set required arguments yourself, and you can pin specific computed values with mock_resource defaults.
# tests/unit/s3.tftest.hcl
# Unit tests: no credentials, no real AWS calls.
mock_provider "aws" {
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mocked-bucket"
}
}
}
# File-level variables apply to every run block below.
variables {
bucket_name = "ajayk-test-bucket"
environment = "dev"
}
run "dev_has_versioning_suspended" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Suspended"
error_message = "dev buckets should not enable versioning"
}
}
run "prod_enables_versioning" {
command = plan
variables {
environment = "prod" # run-level values override file-level ones
}
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "prod buckets must enable versioning"
}
}
run "public_access_fully_blocked" {
command = plan
assert {
condition = alltrue([
aws_s3_bucket_public_access_block.this.block_public_acls,
aws_s3_bucket_public_access_block.this.block_public_policy,
aws_s3_bucket_public_access_block.this.ignore_public_acls,
aws_s3_bucket_public_access_block.this.restrict_public_buckets,
])
error_message = "all four public access block settings must be true"
}
}
# Negative test: the validation rule must reject this input.
run "rejects_bad_environment" {
command = plan
variables {
environment = "qa"
}
expect_failures = [var.environment]
}
# apply against the mock: computed values come from mock defaults.
run "arn_output_uses_mock" {
command = apply
assert {
condition = output.bucket_arn == "arn:aws:s3:::mocked-bucket"
error_message = "unexpected bucket ARN"
}
}
Run only the unit directory:
terraform test -test-directory=tests/unit
Real output from my run:
tests/unit/s3.tftest.hcl... in progress
run "dev_has_versioning_suspended"... pass
run "prod_enables_versioning"... pass
run "public_access_fully_blocked"... pass
run "rejects_bad_environment"... pass
run "arn_output_uses_mock"... pass
tests/unit/s3.tftest.hcl... tearing down
tests/unit/s3.tftest.hcl... pass
Success! 5 passed, 0 failed.
Use command = plan wherever you can: it is faster and asserts on planned values. Switch to apply only when the assertion depends on a computed attribute such as an ARN or ID. For other mocking tools, override_resource, override_data and override_module replace a single address and work with real providers too; they are documented on the mocking page.
Step 4: Watch a test fail and export JUnit
A test you have never seen fail is decoration. I broke the module so dev buckets also got "Enabled" and ran:
terraform test -test-directory=tests/unit -junit-xml=junit.xml
run "dev_has_versioning_suspended"... fail
Error: Test assertion failed
on tests/unit/s3.tftest.hcl line 21, in run "dev_has_versioning_suspended":
21: condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Suspended"
├────────────────
│ Diff:
│ --- actual
│ +++ expected
│ - "Enabled"
│ + "Suspended"
dev buckets should not enable versioning
...
Failure! 4 passed, 1 failed.
The exit code is non-zero, and junit.xml contains a <testcase> with a <failure> element that CI systems render natively. The other useful flags, all listed in the CLI reference: -filter=FILE for a single file, -verbose to print the plan or state for each run, -json for machine output.
Step 5: Add an integration test against a real account
Unit tests prove your logic. They cannot prove AWS accepts the configuration. For that, a second test directory uses the real provider, applies, asserts and then destroys everything in reverse order at the end of the file.
# tests/integration/s3.tftest.hcl
# Creates and destroys a real bucket. Run in a sandbox account only.
provider "aws" {
region = "eu-west-1"
}
# bucket_name is deliberately NOT set here: CI passes a unique name
# with -var, and test-file values would override -var.
variables {
environment = "prod"
}
run "creates_bucket" {
command = apply
assert {
condition = output.bucket_arn == "arn:aws:s3:::${var.bucket_name}"
error_message = "bucket ARN does not match the requested name"
}
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "prod bucket did not enable versioning"
}
}
terraform test -test-directory=tests/integration \
-var="bucket_name=tt-${GITHUB_RUN_ID:-local-$(date +%s)}"
Watch the teardown lines. If cleanup fails (a non-empty bucket, an expired token), Terraform tells you which resources it left behind, and you are paying for them until you delete them by hand. Keep this job away from production state entirely; my infrastructure as code in 2026 post covers what happens when test and production credentials blur.
Step 6: Lint with TFLint and the AWS ruleset
validate knows the schema. TFLint knows the provider: it flags instance types that do not exist, deprecated syntax, untyped and unused variables. Create .tflint.hcl:
# .tflint.hcl
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "aws" {
enabled = true
version = "0.49.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
tflint --init # downloads the AWS ruleset from GitHub releases
tflint --recursive --format compact
To see it work, I added a deliberately bad file with instance_type = "t3.mega" and an untyped, unused variable:
3 issue(s) found:
bait.tf:5:1: Warning - `unused` variable has no type (terraform_typed_variables)
bait.tf:5:1: Warning - variable "unused" is declared but not used (terraform_unused_declarations)
bait.tf:3:19: Error - "t3.mega" is an invalid value as instance_type (aws_instance_invalid_type)
TFLint exits 2 when it finds errors. terraform validate passed the same file, because t3.mega is a perfectly valid string.
Step 7: Scan for misconfigurations with Checkov and Trivy
Scanners catch what nobody wrote: missing encryption, logging, replication. Trivy replaced tfsec, which is deprecated. Running both is reasonable because they disagree.
python3 -m venv .venv && .venv/bin/pip install checkov==3.3.29
.venv/bin/checkov -d . --framework terraform --quiet --compact
trivy config --severity HIGH,CRITICAL --exit-code 1 .
On the example module, Checkov reported Passed checks: 9, Failed checks: 6, including CKV_AWS_18 (access logging), CKV_AWS_145 (KMS encryption) and CKV_AWS_144 (cross-region replication). Trivy reported one HIGH finding, AWS-0132, for not using a customer-managed KMS key. Fix what matters, and suppress the rest inline with a reason a reviewer can argue with:
#trivy:ignore:AWS-0132
resource "aws_s3_bucket" "this" {
#checkov:skip=CKV_AWS_144:Single-region dev bucket, replication not required
bucket = var.bucket_name
# ...
}
After that change Checkov showed Skipped checks: 1 and Trivy reported nothing. Note the placement: the Trivy comment goes on the line above the block, the Checkov comment inside it.
Step 8: Use Terratest v2 when HCL assertions are not enough
terraform test covers most module testing now. Reach for Terratest when you need to do something after apply that HCL cannot: send HTTP requests, SSH in, query a Kubernetes API. Version 2.0.0 (23 September 2026) split it into 16 Go modules under /v2 paths and removed the non-context helpers, so terraform.Apply is now terraform.ApplyContext(t, ctx, ...) and random.UniqueId is random.UniqueID. This test compiles against v2.0.0:
// test/s3_test.go
package test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/gruntwork-io/terratest/modules/core/v2/random"
"github.com/gruntwork-io/terratest/modules/terraform/v2"
"github.com/stretchr/testify/assert"
)
func TestS3BucketModule(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Unique name so parallel CI runs never collide on a global S3 name.
name := fmt.Sprintf("tt-%s", strings.ToLower(random.UniqueID()))
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "..",
Vars: map[string]any{
"bucket_name": name,
"environment": "dev",
},
})
defer terraform.DestroyContext(t, ctx, opts) // always clean up
terraform.InitAndApplyContext(t, ctx, opts)
arn := terraform.OutputContext(t, ctx, opts, "bucket_arn")
assert.Equal(t, "arn:aws:s3:::"+name, arn)
}
cd test
go mod init example.com/s3-bucket-test
go get github.com/gruntwork-io/terratest/modules/terraform/v2@v2.0.0 \
github.com/gruntwork-io/terratest/modules/core/v2@v2.0.0 \
github.com/stretchr/testify@v1.12.1
go test -v -timeout 30m ./...
Step 9: Run the cheap checks before every commit
pre-commit-terraform wraps the tools above as Git hooks. It assumes the binaries are on your PATH.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.109.2
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- id: terraform_trivy
- id: terraform_checkov
args:
- --args=--quiet
pipx install pre-commit
pre-commit install
pre-commit run --all-files
Leave terraform test out of the hook if your integration directory needs credentials. Developers will disable a hook that asks for an AWS login.
Step 10: Wire it into GitHub Actions
Pin every action to a commit SHA. In March 2026 attackers force-pushed malicious code to 76 of 77 tags of aquasecurity/trivy-action, and a tag pin would have pulled it in. The SHAs below are the commits behind each release tag on 20 October 2026.
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
static-and-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.6"
terraform_wrapper: false
- uses: terraform-linters/setup-tflint@6ffdbaa3be476b3431f7275c26966ef32ff60337 # v6.3.2
with:
tflint_version: v0.64.0
cache: true
- uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1
with:
version: v0.76.0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- run: pip install checkov==3.3.29
- run: terraform fmt -check -recursive
- run: terraform init -backend=false -input=false
- run: terraform validate
- run: tflint --init && tflint --recursive
env:
GITHUB_TOKEN: ${{ github.token }} # avoids GitHub API rate limits
- run: checkov -d . --framework terraform --quiet --compact
- run: trivy config --severity HIGH,CRITICAL --exit-code 1 .
- run: terraform test -test-directory=tests/unit -junit-xml=junit.xml
integration:
if: github.ref == 'refs/heads/main'
needs: static-and-unit
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # OIDC, no stored AWS keys
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.6"
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::111122223333:role/terraform-test-sandbox
aws-region: eu-west-1
- run: terraform init -input=false
- run: >
terraform test -test-directory=tests/integration
-var="bucket_name=tt-${{ github.run_id }}"
The account ID and role are placeholders; the OIDC trust setup is in my GitHub Actions OIDC guide. Point the role at a sandbox account with nothing in it you would miss.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Error: Unknown condition value ... executed a run block with command = plan |
Assertion uses a computed value (ARN, ID) that is unknown at plan time | Use command = apply with a mock provider, or set override_during = plan on the override |
Error: No value for required variable in a test |
The module needs a variable the test file never sets | Add it to a file-level variables {} block or pass -var |
-var value ignored in a test |
Values in test files take precedence over CLI variables | Remove the variable from the test file's variables block |
Integration run shows skip then failed to get shared config profile |
No usable AWS credentials in the environment | Configure OIDC or a profile; keep mocked tests in a separate directory |
expect_failures run fails with an unexpected error |
A different check failed, or the failure happens in a later phase than command |
Target the exact checkable object, such as var.environment, and match the phase |
tflint --init fails with 403 API rate limit exceeded |
Anonymous GitHub API calls are rate limited | Set GITHUB_TOKEN (any token, no scopes needed) or enable cache: true in setup-tflint |
Plugin "aws" not found. Did you run "tflint --init"? |
Ruleset not installed for this config | Run tflint --init in the same directory as .tflint.hcl, or point --config at it |
| Checkov or Trivy flags a deliberate choice | The check does not fit this resource | Suppress inline with #checkov:skip=ID:reason or #trivy:ignore:ID, never globally |
| Test leaves real resources behind | Teardown failed (non-empty bucket, expired credentials) | Read the teardown error, delete manually, add force_destroy only in test-only fixtures |
Terratest v2 build errors on terraform.Apply or random.UniqueId |
v1 API removed in v2.0.0 | Use ApplyContext(t, ctx, ...) and random.UniqueID, with /v2 import paths |
FAQ
What is the difference between terraform validate, TFLint and Checkov?
terraform validate checks the configuration against the provider schema: syntax, argument names, references. TFLint checks provider-specific correctness and style, such as invalid instance types and unused variables. Checkov (and Trivy) check security and compliance: encryption, logging, public access. They overlap very little, so run all three.
Should I use terraform test or Terratest?
Use terraform test for module logic and most integration checks, because it is HCL, ships with Terraform and supports mocks. Use Terratest when a test must do something after apply that HCL cannot, such as calling an HTTP endpoint or a Kubernetes API. Many teams run both: terraform test on every PR, Terratest nightly.
Do Terraform mock providers need cloud credentials?
No. A mock_provider block never calls the real API, so unit tests run with no credentials, which makes them safe for pull requests from forks and for AI agents working in a sandbox. Mocks generate values only for computed attributes; required arguments still come from your configuration.
Does terraform test work with OpenTofu?
Mostly. OpenTofu's tofu test runs .tftest.hcl files as well as its own .tofutest.hcl (which wins if both exist with the same name), and it supports mock_provider and override_resource, though the two tools have diverged in places since the fork. Check the OpenTofu testing docs for your version before sharing test files between them.
When a Terraform change lands in front of me, the first thing I check is not the code, it is whether the plan deletes or replaces anything, and the second is whether the module has a negative test proving its validation rules reject bad input. Everything in this guide is in service of those two questions. In production I run stages 1 to 4 on every pull request and the integration job only on merge, in an account that holds nothing but test fixtures. The official references are the terraform test language docs, the mocking docs, the TFLint docs, the Checkov docs and the Trivy misconfiguration docs.
Related: Infrastructure as Code in 2026: Two Forks at 1.16 and 1.13, a $6.4B Owner, 912 Public State Files, and One Agent That Ran terraform destroy, Policy as Code for AI Agents in 2026: Cedar at the Gateway, Rego in the Hook, and the 11.2% the Model Still Lets Through and DevOps Still Matters in 2026: AI Cut Delivery Stability 7.2%, Then Doubled Merged PRs and Added 91% to Review Time.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026)
Step-by-step GitHub Actions OIDC setup for AWS, Google Cloud and Azure with no stored secrets: trust policies pinned to sub claims, immutable subjects, environments, pinned actions, and fixes for sts:AssumeRoleWithWebIdentity, audience and AADSTS700213 errors.
14 minOct 20, 2026AI-Generated Terraform in 2026: Benchmarks, Failure Modes, Guardrails
Can you trust an LLM to write Terraform, Kubernetes YAML or Helm? What IaC-Eval, DPIaC-Eval and three 2025 to 2026 papers measured (19% to 30% first-try success, 8.4% policy compliance), the failure modes they found, and the plan, policy and review gates that make AI-written IaC safe.
12 minOct 20, 2026How to Build Multi-Region Failover with Route 53 and Cloud DNS (2026)
Step-by-step multi-region failover: active-passive vs active-active, Route 53 health checks and failover records in Terraform, ARC routing controls, Cloud DNS failover policies, Aurora Global Database and Spanner, failover testing, and fixes for DNS TTL and caching problems.
16 min