How to Test Terraform: terraform test, TFLint and Checkov (2026)

Oct 20, 2026 · 13 min · Ajay Kumar

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.

Terraform test layers, cheapest first 1. fmt, validate syntax, invented arguments, bad refs seconds, local 2. TFLint provider rules, bad instance types, unused variables seconds, local 3. Checkov, Trivy public access, missing encryption, wide IAM policies seconds, local 4. terraform test mock providers, module logic, validation rules no credentials 5. Integration real apply, destroy; test apply or Terratest v2 minutes, costs money Stages 1 to 3 run in pre-commit and on every pull request. Stage 4 runs on every pull request. Stage 5 runs on merge to main or nightly, in a sandbox account, with short-lived OIDC credentials. A failure at any stage stops the pipeline; later stages never run against code an earlier one rejected. Versions tested: Terraform 1.16.6, TFLint 0.64.0, Checkov 3.3.29, Trivy 0.76.0, Terratest 2.0.0.
The five layers this guide builds, in the order the pipeline runs them. Timings are qualitative; the versions are the ones used for every command below.

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