| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/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.
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:
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.hclHow 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:
mkdir -p ~/terraform-labs/terraform-test/modules/label ~/terraform-labs/terraform-test/testsPin the random provider in versions.tf so terraform init downloads a known plugin version:
terraform {
required_version = ">= 1.9.0"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}Add root variables the module and tests will override:
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:
locals {
full_name = "${var.name_prefix}-${var.environment}"
}
resource "terraform_data" "label" {
input = local.full_name
}variable "environment" {
type = string
}
variable "name_prefix" {
type = string
}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:
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:
cd ~/terraform-labs/terraform-test && terraform initSample 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:
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):
terraform test -filter=tests/defaults.tftest.hclSample 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:
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:
terraform test -filter=tests/main.tftest.hclSample 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:
terraform test -filter=tests/main.tftest.hcl -verboseIn 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:
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:
terraform test -filter=tests/cli_var.tftest.hcl -var 'environment=prod' -var 'name_prefix=demo'Sample 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 outputsmodule.<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:
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:
terraform test -filter=tests/mock.tftest.hclSample 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:
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:
terraform test -filter=tests/label_module.tftest.hclSample 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:
terraform test -filter=tests/mock.tftest.hclUsing -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:
terraform test -filter=tests/mock.tftest.hcl -jsonSample output (last lines trimmed):
{"@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:
terraform test -filter=tests/defaults.tftest.hcl -junit-xml=test-results.xmlSample file content:
<?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:
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:
terraform test -filter=tests/expect_failures.tftest.hclSample 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:
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:
terraform test -filter=tests/fail.tftest.hclSample 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:
ls ~/terraform-labs/terraform-test/terraform.tfstate 2>&1Sample output:
ls: cannot access '/root/terraform-labs/terraform-test/terraform.tfstate': No such file or directoryRemove the lab tree when you no longer need it:
rm -rf ~/terraform-labs/terraform-testReferences
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.

