Skip to main content
RunBook Academy

TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform

terraform test in Depth

Intermediate⏱ ~14 minbash

What you'll learn

  • Author a .tftest.hcl file with run blocks, variables, and assertions
  • Distinguish `command = plan` from `command = apply` and pick the right one per test
  • Write assertions on resource attributes, output values, and the resource graph
  • Apply a per-module test discipline: every module ships with tests; tests run on every PR

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Terraform 1.6 introduced a native test framework. The test files are HCL files with a .tftest.hcl extension. The engine is terraform test, shipped with the same binary. There is no separate install, no separate plugin, no provider version to manage. The output is structured: pass, fail, with a file and line for each assertion. The framework is the right tool for unit and integration tests in 2026. The community alternatives (terratest, kitchen-terraform) are older and more complex; they solve problems the native framework has absorbed.

What the framework does

terraform test reads every *.tftest.hcl file in the working directory and runs each run block in sequence. Each run block is a self-contained plan or apply with its own variables, its own provider configuration, and its own assertions. The framework reports pass or fail per run.

modules/s3_bucket/
  main.tf
  variables.tf
  outputs.tf
  tests/
    unit.tftest.hcl       # run with mock_provider
    integration.tftest.hcl # run against a sandbox
# Severity: READ-ONLY - runs in process.
terraform test

Output:

tests/unit.tftest.hcl... in progress
  run "creates_bucket"... pass
  run "enforces_encryption"... pass
tests/unit.tftest.hcl... pass

tests/integration.tftest.hcl... in progress
  run "creates_bucket_in_sandbox"... pass
tests/integration.tftest.hcl... pass

Success! 3 passed, 0 failed.

Exit 0 on a clean run. Exit 1 on any failed assertion.

The file format

A .tftest.hcl file looks like HCL, with a top-level run block and an optional variables and provider block. The file is parsed by the same parser that reads your configuration.

# modules/s3_bucket/tests/unit.tftest.hcl

# Optional: variables that apply to the whole file
variables {
  bucket_name = "test-bucket"
  environment = "test"
}

# Optional: a mock provider for the whole file
mock_provider "aws" {
  alias = "mock"
}

# Each run block is one test
run "creates_bucket" {
  command = plan

  assert {
    condition     = aws_s3_bucket.this.bucket == "test-bucket"
    error_message = "bucket name did not propagate"
  }

  assert {
    condition     = aws_s3_bucket.this.tags["Environment"] == "test"
    error_message = "environment tag missing"
  }
}

The run block is the unit of testing. The assert blocks are the assertions. The command field controls whether the run is a plan or an apply.

The command field

Each run block chooses between plan and apply. The choice is the most consequential decision in the test.

command = plan   - read-only, no state changes, runs in
                   seconds. The right choice for unit tests
                   and most integration tests.

command = apply  - runs the apply, captures the resulting
                   state, runs in tens of seconds. The right
                   choice for integration tests that need to
                   verify resource attributes after creation.

The rule of thumb:

  • Unit tests use command = plan. The mock provider returns whatever the override or defaults say. The plan is the proof.
  • Integration tests use command = apply when the test must verify a resource attribute that is only populated after the cloud accepts the create. Examples: the ARN of a freshly created bucket, the DNS name of a freshly created load balancer, the id of a freshly provisioned EC2 instance.

A command = apply test without a real provider is a contradiction. The mock provider does not have a real backend. The apply will succeed only because the mock cooperates.

Assertions

The assert block is a HCL boolean expression. The condition is the test. The error message is the failure diagnosis.

assert {
  condition     = aws_s3_bucket.this.encryption[0].sse_algorithm == "AES256"
  error_message = "SSE algorithm must be AES256"
}

assert {
  condition     = length(aws_subnet.public) == 3
  error_message = "expected three public subnets, got ${length(aws_subnet.public)}"
}

assert {
  condition     = contains(["standard", "intelligent-tiering"], aws_s3_bucket.this.lifecycle_rule[0].status)
  error_message = "lifecycle status must be standard or intelligent-tiering"
}

What assert can check:

  • Resource attributes (aws_s3_bucket.this.bucket)
  • Output values (output.arn)
  • Computed values from the plan
  • The resource graph (counts of resources, references between resources)
  • Variable values

What assert cannot check:

  • That the cloud accepted the configuration (only terraform apply knows). The assertion is on the plan output, not on the cloud response.
  • That the configuration will work in three months. The test is point-in-time.
  • Performance, security, or cost. Those are separate checks.

The variables block

The variables block at the top of the file provides defaults for every run block. A run block can override them with its own variables block.

variables {
  bucket_name = "test-bucket"
  environment = "test"
}

run "with_override" {
  command = plan

  variables {
    bucket_name = "override-bucket"
  }

  assert {
    condition     = aws_s3_bucket.this.bucket == "override-bucket"
    error_message = "run-level variables did not override file-level variables"
  }
}

The variables block is the most common way to test the same module with different inputs. The test is the combination of inputs and assertions.

The provider block

For integration tests, the file can declare a real provider. The block is the same shape as in a normal configuration, but it is scoped to the test.

provider "aws" {
  region                      = "eu-west-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = "http://localhost:4566"
  }
}

The access_key and secret_key are placeholders for LocalStack. For a real sandbox, the values come from the CI environment.

The per-module discipline

The framework is the gate. The discipline is the rule that makes the gate work.

Every module ships with tests.
  - tests/unit.tftest.hcl is required.
  - tests/integration.tftest.hcl is required for modules
    that hit the cloud.

Tests run on every PR.
  - Unit tests run on every PR.
  - Integration tests run on merge to main and on
    nightly.

A test failure is a pipeline failure.
  - The merge is blocked.
  - The contributor fixes the configuration and
    re-runs.

New module behaviour is a new test.
  - A contributor adding a new variable adds a test that
    exercises the new variable.
  - A contributor adding a new conditional adds a test
    that exercises the conditional.

The test is the contract. A module without a test is a module whose contract is “whatever the implementation happens to do.” That is not a contract.

How to run

# Run all tests in the working directory
terraform test

# Run a specific test file
terraform test -test-directory=tests/unit

# Run with verbose output
terraform test -verbose

# Filter by run block name
terraform test -filter=creates_bucket

The -filter flag is the right tool when one test is flaky and you want to re-run it without the full suite.

Production failure modes

1. Tests are flaky on local but green in CI

Symptom: terraform test exits 1 on the contributor’s laptop, exits 0 in CI. Cause: the test depends on a state file or a network resource that exists locally but not in CI. Fix: the test should not depend on local state. The test is read-only against the configuration and the mock provider. If the test relies on a real provider, it is an integration test and belongs in the sandbox.

2. command = apply in a unit test

Symptom: the test runs against the mock provider and the apply step succeeds, but the test takes 30 seconds. Cause: the apply engine runs against the mock. The mock cooperates but the engine still runs through the apply graph. Fix: use command = plan for unit tests. The apply is for integration tests.

3. Test asserts on a value that is only known after apply

Symptom: condition = aws_instance.this.id fails with “instance is null.” Cause: id is only populated after a real apply. The plan does not know the value. Fix: either use command = apply (integration test) or mock the value with an override_resource block (unit test).

4. Tests are not in the module

Symptom: a module has no tests/ directory. Cause: the contributor who wrote the module did not write tests. Fix: enforce the discipline in CI. A module without a test file is a module that does not merge.

5. Test file fails to parse

Symptom: terraform test exits 2 with Error: Unsupported argument. Cause: the test file uses a syntax that the Terraform version in CI does not support. The native framework is new and the syntax has shifted between 1.6 and 1.9. Fix: pin the Terraform version in CI to match the version the contributor uses locally.

6. The test passes but the production apply fails

Symptom: the test is green, the apply in staging fails on a region-specific constraint. Cause: the test is a unit test; the mock provider accepted the configuration. Fix: unit tests are not the gate for region-specific behaviour. Add an integration test, or accept the gap and add the finding to the runbook.

7. Test suite is slow across the module

Symptom: terraform test across a 40-module monorepo takes 5 minutes. Cause: the test framework runs serially across modules. Fix: split the test runs across CI matrices. Each module’s tests run on a separate runner. The suite finishes in the time of the slowest module.

Security and performance implications

The test framework is local-first. Unit tests run in process with no network calls. The CI does not need IAM permissions for the unit test step.

Integration tests run against a real provider. The CI needs short-lived credentials scoped to a sandbox account. The blast radius of a leaked integration test credential is the sandbox account, not the production account.

A unit test suite with 10 runs takes under a second. A suite with 100 runs takes a few seconds. The cost scales with the number of runs, not the size of the configuration. The --filter flag is the right tool when you want to re-run a single test without the full suite.

Production guidance

  • Every module ships with tests/unit.tftest.hcl. The path is the convention.
  • Unit tests run on every PR. Integration tests run on merge to main and on nightly.
  • command = plan is the default for unit tests. Use command = apply only for integration tests that need the post-apply state.
  • Write the assertion first. The test is the contract.
  • Treat a failing test as a pipeline failure. The merge is blocked.
  • Pin the Terraform version in CI. The test syntax shifts between minor versions.

Verification

# Run the unit tests
terraform test -test-directory=tests/unit

# Run with verbose output
terraform test -verbose

# Show the test file structure
find tests -name '*.tftest.hcl' -print

A clean run exits 0. The verbose output shows each run block with its pass or fail status.

Knowledge check · 7 questions

  1. Q1. Which Terraform version introduced the native `terraform test` framework?

  2. Q2. The `terraform test` framework requires a separate installation beyond the Terraform binary.

  3. Q3. Which `command` value is the right default for unit tests?

  4. Q4. An assertion fails because `aws_instance.this.id` is null. What is the most likely cause?

  5. Q5. Which of the following are appropriate per-module test disciplines? (Select all that apply.)

  6. Q6. What kind of assertion is the right primary form for a terraform test?

  7. Q7. A team wants to adopt the native test framework in a 30-module monorepo. What is the right starting point?

Passing score: 75%. Answers are checked in this browser.