Terraform Test Framework with Examples

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
hashicorp/random 3.6.3
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope The terraform test command, .tftest.hcl test files, run blocks with plan and apply, assert and error_message, expect_failures, test variables, mock_provider and mock_resource, module-level tests, -filter, -verbose, -json, and -junit-xml output. Does not cover Terratest, kitchen-terraform, Sentinel, or a full CI/CD pipeline tutorial.
Related guides Terraform validation and checks
Terraform modules
Terraform output values
Terraform variables
Terraform Associate certification course

You refactored a module last week and a teammate broke an output rename in review. terraform validate still passed because the syntax was fine. What you wanted was an automated check that module.label.full_name still composes name_prefix and environment the way your callers expect.

The terraform test command runs .tftest.hcl files beside your configuration. Each file contains run blocks that execute plan or apply, then evaluate assert conditions against resource attributes, locals, and outputs. You can mock providers when a plan-only test needs computed values without calling a real API.

This lesson is an Advanced / Professional topic in the Terraform Associate track. It complements Terraform validation and checks, which covers language-level validation and check blocks during normal runs. Here the focus is the dedicated test runner, mock providers, and machine-readable output for CI.

Work in ~/terraform-labs/terraform-test/ on the Terraform lab environment on Ubuntu. Examples use terraform_data and a mocked random provider before any scenario that applies real resources.

IMPORTANT
Terraform tests can create real infrastructure. A run block defaults to command = apply unless you set command = plan. Apply-based tests create real resources in test-specific state. Terraform attempts to destroy those resources during test cleanup, but cleanup can fail or be interrupted, so never assume real infrastructure is gone without verifying it. Prefer plan tests and mock_provider when you do not need a live apply. This lab uses terraform_data and hashicorp/random only — no cloud account.

The finished layout looks like this:

text
terraform-test/
├── main.tf
├── variables.tf
├── versions.tf
├── modules/
│   └── label/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── tests/
    ├── defaults.tftest.hcl
    ├── main.tftest.hcl
    ├── fail.tftest.hcl
    ├── expect_failures.tftest.hcl
    ├── mock.tftest.hcl
    ├── label_module.tftest.hcl
    └── cli_var.tftest.hcl

How Terraform tests work

Terraform discovers files ending in .tftest.hcl or .tftest.json under the test directory (default tests/). The terraform test command executes each file sequentially unless you mark run blocks as parallel.

Building block Role
.tftest.hcl file One test suite; can hold file-level variables, mock_provider, and multiple run blocks
run block Named scenario with optional command, variables, module, providers, and assert blocks
command plan or apply for that run; default is apply
assert condition must be true; error_message prints when it fails
expect_failures List of objects expected to fail validation or custom conditions during the run
File-level variables Input values for every run in that file
mock_provider Supplies provider schema with generated or fixed values instead of a real API

Each run block behaves like Terraform executing in your configuration directory with the options you set. Assertions can read module.*, output.*, and resource attributes visible at the chosen command phase.

Plan tests are fast and avoid creating infrastructure. Apply tests can assert values only known after creation, but they carry the real-infrastructure warning above. Mock providers let plan tests see fixed computed attributes without credentials.


Create the lab configuration

Create the project directory:

bash
mkdir -p ~/terraform-labs/terraform-test/modules/label ~/terraform-labs/terraform-test/tests

Pin the random provider in versions.tf so terraform init downloads a known plugin version:

hcl
terraform {
  required_version = ">= 1.9.0"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

Add root variables the module and tests will override:

hcl
variable "environment" {
  type        = string
  description = "Deployment environment label"
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "name_prefix" {
  type        = string
  description = "Prefix for resource names"
  default     = "lab"
}

The reusable label child module composes name_prefix and environment into one string:

hcl
locals {
  full_name = "${var.name_prefix}-${var.environment}"
}

resource "terraform_data" "label" {
  input = local.full_name
}
hcl
variable "environment" {
  type = string
}

variable "name_prefix" {
  type = string
}
hcl
output "full_name" {
  value = local.full_name
}

Save those three files under modules/label/ as main.tf, variables.tf, and outputs.tf.

The root module calls the child module, wires a marker resource, and exposes outputs tests can assert:

hcl
module "label" {
  source = "./modules/label"

  environment = var.environment
  name_prefix = var.name_prefix
}

resource "terraform_data" "marker" {
  input = module.label.full_name
}

resource "random_id" "suffix" {
  byte_length = 2
}

output "full_name" {
  value = module.label.full_name
}

output "marker_input" {
  value = terraform_data.marker.input
}

output "random_suffix" {
  value = random_id.suffix.hex
}

Change into the lab directory and initialize providers and modules:

bash
cd ~/terraform-labs/terraform-test && terraform init

Sample output:

output
Terraform has been successfully initialized!

Write your first Terraform test

Create tests/defaults.tftest.hcl with a plan-only run that checks the default variable values compose lab-dev:

hcl
run "plan_label_defaults" {
  command = plan

  assert {
    condition     = module.label.full_name == "lab-dev"
    error_message = "Default environment should produce lab-dev"
  }

  assert {
    condition     = output.full_name == "lab-dev"
    error_message = "Root output should expose module label"
  }
}

command = plan keeps the run read-only. Each assert block is independent — if any condition is false, Terraform fails the run and prints your error_message.

Run the test suite (starting with this one file):

bash
terraform test -filter=tests/defaults.tftest.hcl

Sample output:

output
tests/defaults.tftest.hcl... in progress
  run "plan_label_defaults"... pass
tests/defaults.tftest.hcl... tearing down
tests/defaults.tftest.hcl... pass

Success! 1 passed, 0 failed.

The tearing down line appears even for plan-only files because Terraform resets test state between files. A passing run means both assertions matched the planned values.


Plan tests versus apply tests

Plan and apply runs answer different questions. A plan test asks whether Terraform would set the right values. An apply test asks whether values after creation match your expectation, including attributes that are (known after apply) during plan.

Create tests/main.tftest.hcl with a file-level variables block and two runs:

hcl
variables {
  environment = "staging"
  name_prefix = "app"
}

run "plan_with_variables_block" {
  command = plan

  assert {
    condition     = module.label.full_name == "app-staging"
    error_message = "variables block should override defaults"
  }
}

run "apply_marker" {
  command = apply

  assert {
    condition     = terraform_data.marker.input == "app-staging"
    error_message = "Applied marker should store composed label"
  }

  assert {
    condition     = output.marker_input == "app-staging"
    error_message = "Output should reflect applied marker"
  }
}

The file-level variables block applies to every run in this file, overriding the root defaults for both plan and apply.

Run only this file:

bash
terraform test -filter=tests/main.tftest.hcl

Sample output:

output
tests/main.tftest.hcl... in progress
  run "plan_with_variables_block"... pass
  run "apply_marker"... pass
tests/main.tftest.hcl... tearing down
tests/main.tftest.hcl... pass

Success! 2 passed, 0 failed.

The apply run created real terraform_data and random_id objects in test-specific state. Terraform attempted cleanup afterward — you should see tearing down in the output — but do not treat that as a guarantee nothing remains. Prefer plan tests when assertions do not need post-apply values. Reserve apply runs for behavior that only exists after creation, and keep the scope small.

Add -verbose when you want the plan or state snapshot printed for each run:

bash
terraform test -filter=tests/main.tftest.hcl -verbose

In the verbose transcript, the plan run lists resources as + create with random_suffix = (known after apply), while the apply run shows concrete attribute values after Terraform creates objects. That difference is the signal to pick command = plan unless you truly need apply-time values.


Test variables and outputs

You can supply test inputs three ways: a file-level variables block inside .tftest.hcl, CLI -var flags, or -var-file pointing at a .tfvars file. All of them override root variable defaults for the terraform test invocation.

The variables block in tests/main.tftest.hcl already demonstrated file-level overrides. For CLI overrides, add tests/cli_var.tftest.hcl:

hcl
run "cli_var_override" {
  command = plan

  assert {
    condition     = module.label.full_name == "demo-prod"
    error_message = "CLI -var flags should override variable defaults"
  }
}

Pass the values on the command line — they apply to the whole test command, so run this file alone:

bash
terraform test -filter=tests/cli_var.tftest.hcl -var 'environment=prod' -var 'name_prefix=demo'

Sample output:

output
tests/cli_var.tftest.hcl... in progress
  run "cli_var_override"... pass
tests/cli_var.tftest.hcl... tearing down
tests/cli_var.tftest.hcl... pass

Success! 1 passed, 0 failed.

Assertions can target any expression visible at the run phase:

  • output.<name> for root outputs
  • module.<name>.<output> for child module outputs
  • Resource attributes such as terraform_data.marker.input
  • Locals indirectly through outputs or resources that reference them

Do not assert against provider-generated values in plan tests unless you mock them — the next section shows why.


Mock Terraform providers

mock_provider blocks return the same schema as a real provider but supply fixed or generated values for computed attributes. That lets plan tests assert on random_id.suffix.hex without executing the real provider resource behavior or performing an apply.

Create tests/mock.tftest.hcl:

hcl
mock_provider "random" {
  mock_resource "random_id" {
    override_during = plan

    defaults = {
      id      = "mock-fixed-id"
      hex     = "deadbeef"
      b64_std = "3q2+7w=="
      b64_url = "3q2-7w"
      dec     = "3735928559"
    }
  }
}

run "mock_random_plan" {
  command = plan

  assert {
    condition     = random_id.suffix.hex == "deadbeef"
    error_message = "Mock provider should supply fixed hex for random_id during plan"
  }
}

override_during = plan is the important detail on Terraform 1.15.8. Without it, hex stays unknown during plan and the assertion fails with an unknown condition value even though the mock block is present.

Run the mock test:

bash
terraform test -filter=tests/mock.tftest.hcl

Sample output:

output
tests/mock.tftest.hcl... in progress
  run "mock_random_plan"... pass
tests/mock.tftest.hcl... tearing down
tests/mock.tftest.hcl... pass

Success! 1 passed, 0 failed.

Verbose mode shows the mocked values in the plan output — hex = "deadbeef" and random_suffix = "deadbeef" in Changes to Outputs — confirming the mock replaced provider computation for that run.

Mock providers are for Terraform logic tests: module composition, conditionals, and output wiring. They do not validate that a real API accepts your arguments. Combine mocked plan tests with a small number of targeted apply tests when you need end-to-end confidence.


Test Terraform modules

Module tests should assert the public interface — inputs you document and outputs callers rely on — rather than internal resource addresses inside the child module.

tests/label_module.tftest.hcl loads only the child module as the configuration under test:

hcl
variables {
  environment = "qa"
  name_prefix = "unit"
}

run "module_label_unit" {
  command = plan

  module {
    source = "./modules/label"
  }

  assert {
    condition     = output.full_name == "unit-qa"
    error_message = "Module output should match composed name"
  }
}

The module block inside run accepts only source; inputs come from the file-level variables block. Local module paths are resolved relative to the root configuration, so source = "./modules/label" matches the layout above. After you add or change module blocks in test files, run terraform init again so Terraform installs the referenced module.

Run the isolated module test:

bash
terraform test -filter=tests/label_module.tftest.hcl

Sample output:

output
tests/label_module.tftest.hcl... in progress
  run "module_label_unit"... pass
tests/label_module.tftest.hcl... tearing down
tests/label_module.tftest.hcl... pass

Success! 1 passed, 0 failed.

This pattern catches renames to output.full_name or changes to the composition logic without spinning up the entire root stack. Asserting only the output keeps the test stable if you later replace terraform_data.label with a local, another resource, or no resource at all while preserving the module contract.


Run specific tests and machine-readable output

CI jobs usually need to run a subset of tests and publish results in a standard format. Terraform exposes flags for both.

Filter one test file

Pass -filter with the path relative to the configuration root, including the tests/ prefix:

bash
terraform test -filter=tests/mock.tftest.hcl

Using -filter=mock.tftest.hcl without the directory matches zero files on Terraform 1.15.8. Repeat -filter to list multiple files.

JSON stream for log parsers

Add -json when your pipeline parses newline-delimited JSON events instead of human text:

bash
terraform test -filter=tests/mock.tftest.hcl -json

Sample output (last lines trimmed):

output
{"@level":"info","@message":"tests/mock.tftest.hcl... tearing down","@module":"terraform.ui","@testfile":"tests/mock.tftest.hcl","type":"test_file",...}
{"@level":"info","@message":"tests/mock.tftest.hcl... pass","@module":"terraform.ui","@testfile":"tests/mock.tftest.hcl","type":"test_file",...}
{"@level":"info","@message":"Success! 1 passed, 0 failed.","@module":"terraform.ui","type":"test_summary","test_summary":{"status":"pass","passed":1,"failed":0,...}}

Each line is a JSON object. Look for type":"test_summary" at the end to gate a pipeline on pass or fail.

JUnit XML for test report widgets

Write JUnit XML when the CI platform expects a standard test report file:

bash
terraform test -filter=tests/defaults.tftest.hcl -junit-xml=test-results.xml

Sample file content:

output
<?xml version="1.0" encoding="UTF-8"?><testsuites>
  <testsuite name="tests/defaults.tftest.hcl" tests="1" skipped="0" failures="0" errors="0">
    <testcase name="plan_label_defaults" classname="tests/defaults.tftest.hcl" time="0.195433305" timestamp="2026-08-12T11:26:03Z"></testcase>
  </testsuite>
</testsuites>

Failed assertions add a <failure> element with the diff, which is useful when Jenkins or GitLab parses JUnit natively. You can pass an absolute path to -junit-xml when the CI workspace requires it.


Read a failed assertion

Not every failing run is a bug. Terraform distinguishes expected validation failures from accidental assertion mistakes.

Test an expected validation failure

An assert block checks that a successful run produces the value you expect. expect_failures is the opposite: it declares that a particular object should fail validation or a custom condition during the run. Use it to lock in negative tests without treating an expected error as a broken test.

The root environment variable already carries a validation block from the lab setup. Add tests/expect_failures.tftest.hcl:

hcl
run "reject_invalid_environment" {
  command = plan

  variables {
    environment = "invalid"
  }

  expect_failures = [
    var.environment
  ]
}

The per-run variables block overrides environment only for this scenario. expect_failures lists the object that must report a failure — here var.environment because the invalid value trips the validation block.

Run the negative test:

bash
terraform test -filter=tests/expect_failures.tftest.hcl

Sample output:

output
tests/expect_failures.tftest.hcl... in progress
  run "reject_invalid_environment"... pass
tests/expect_failures.tftest.hcl... tearing down
tests/expect_failures.tftest.hcl... pass

Success! 1 passed, 0 failed.

A passing run means Terraform did reject the bad input. For more on the underlying validation syntax, see Terraform validation and checks.

Accidental assertion failure

tests/fail.tftest.hcl documents what a broken assert looks like when you expected success:

hcl
run "deliberate_failure" {
  command = plan

  assert {
    condition     = module.label.full_name == "this-value-does-not-exist"
    error_message = "This assert is intentionally wrong for the article"
  }
}

Run it expecting a non-zero exit status:

bash
terraform test -filter=tests/fail.tftest.hcl

Sample output:

output
tests/fail.tftest.hcl... in progress
  run "deliberate_failure"... fail

Error: Test assertion failed

  on tests/fail.tftest.hcl line 5, in run "deliberate_failure":
   5:     condition     = module.label.full_name == "this-value-does-not-exist"
    ├────────────────
    │ Diff:
    │ --- actual
    │ +++ expected
    │ - "lab-dev"
    │ + "this-value-does-not-exist"

This assert is intentionally wrong for the article
tests/fail.tftest.hcl... tearing down
tests/fail.tftest.hcl... fail

Failure! 0 passed, 1 failed.

Terraform prints the file, line, and a diff between actual and expected values. Fix the configuration or the assertion — not the transient test state.


Common Terraform test failures

Symptom Likely cause Fix
Test assertion failed with a diff condition does not match the value at this command phase Fix the config or update the expected value; check plan vs apply timing
Unknown condition value during plan Asserting a computed attribute without mock or apply Add mock_provider with override_during = plan, or switch to command = apply
Real test resources remain Test interrupted or teardown failed Inspect the test output and state information, then destroy leftover test infrastructure before rerunning
Mock schema mismatch defaults keys do not match current provider schema Align keys with the provider docs; re-run after provider upgrades
0 passed, 0 failed with no runs Wrong -filter path Use tests/<file>.tftest.hcl, not bare filename
Unexpected variable value File-level variables merged with CLI -var across files Run one file per terraform test invocation when overrides differ
Test file not discovered Wrong extension or directory Use .tftest.hcl under tests/ or set -test-directory
Brittle assertion on random values Asserting unmocked provider-generated attributes in plan Mock the resource or assert on inputs you control

Lab cleanup

Apply-based runs in this lab normally show tearing down after each test file, and a successful suite leaves no terraform.tfstate in the directory. That outcome is not guaranteed — interrupted tests, provider errors, or failed destroys can leave resources behind. Confirm no state file remains when you are finished experimenting:

bash
ls ~/terraform-labs/terraform-test/terraform.tfstate 2>&1

Sample output:

output
ls: cannot access '/root/terraform-labs/terraform-test/terraform.tfstate': No such file or directory

Remove the lab tree when you no longer need it:

bash
rm -rf ~/terraform-labs/terraform-test

References


Summary

The Terraform test framework adds a first-class runner for module and configuration checks. You place .tftest.hcl files under tests/, define run blocks with command = plan or command = apply, and express expectations with assert conditions and error_message text. Use expect_failures when a run should deliberately trip validation or custom conditions. Plan tests stay fast and avoid infrastructure; apply tests can read post-create values but create real resources Terraform attempts to clean up afterward.

Test variables arrive through file-level variables blocks, per-run variables, -var, or -var-file. mock_provider lets plan tests see fixed computed attributes — set override_during = plan when asserting on values such as random_id.hex without an apply. Module-focused runs use the module block inside run to test outputs callers rely on, not internal resource addresses.

For CI, -filter limits which files run, -json streams structured events, and -junit-xml writes standard test reports. Keep assertions on values you control or explicitly mock; provider-generated plan-time unknowns are the most common source of flaky tests.

When a failure appears, read the diff Terraform prints and decide whether the bug is in configuration or expectation. Prefer plan plus mocks for everyday module regression checks, use expect_failures for negative validation tests, and reserve apply runs for the few behaviors that truly need created state — verifying cleanup when real providers are involved.

Frequently Asked Questions

1. Does terraform test create real infrastructure?

It can. Each run block defaults to command apply, which creates and updates real resources in test-specific state. Terraform attempts to destroy those resources during test cleanup, but cleanup can fail or be interrupted, so never assume real infrastructure is gone without verifying it. Prefer command plan when you only need to validate configuration logic, and use mock_provider when you want provider-shaped values without a live apply.

2. Where do Terraform test files live?

By default Terraform looks in a tests directory next to your configuration. Files must use the .tftest.hcl or .tftest.json extension. Override the directory with -test-directory if your team uses a different layout.

3. What is the difference between a plan test and an apply test?

A plan test runs terraform plan and evaluates assert conditions against the planned values. An apply test runs terraform apply, so attributes that are only known after creation become available, but Terraform may create real resources during the run. Terraform normally tears down resources created by apply-based test runs during cleanup. However, teardown is not guaranteed to succeed if the test is interrupted or destruction fails, so verify cleanup when testing against real providers.

4. When should I use mock_provider?

Use mock_provider when you need provider schema and computed attributes during a plan test without credentials or real infrastructure. Mocked values are not a substitute for integration testing against a live API, but they let you assert module logic and expression wiring quickly in CI.

5. How do I run only one Terraform test file?

Pass -filter with the path under the test directory, for example terraform test -filter=tests/mock.tftest.hcl. Bare filenames without the tests/ prefix are not matched on Terraform 1.15.8.

6. How is terraform test different from variable validation blocks?

validation, precondition, postcondition, and check blocks are language features evaluated during normal plan and apply. terraform test is a separate command that executes .tftest.hcl files with run blocks and assert conditions. Use language blocks to guard production runs; use terraform test to automate repeatable test scenarios in CI.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)