TerraformXIV · Modules: Reusable Building BlocksProduction Terraform
Testing Modules with terraform test
What you'll learn
- Run the native terraform test framework
- Write unit tests with mock providers and plan tests with real providers
- Cover validation, output stability, and integration scenarios
- Wire the tests into CI for every pull request
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
terraform test is the native test framework for
Terraform modules. It was declared stable in Terraform
1.6 and is the recommended way to test modules in 1.9.x.
The framework is built into the binary. The tests are
plain HCL in files ending in .tftest.hcl. The framework
supports unit tests with mock providers and integration
tests with real providers.
The discipline of testing modules is what separates a module that is safe to upgrade from a module that is probably safe to upgrade. The lesson teaches the framework, the file structure, and the test coverage.
The two test modes
The framework supports two modes:
terraform test
|
+-------------+-------------+
| |
Unit tests Plan tests
(mock providers) (real providers)
| |
No cloud access Real cloud API calls
Fast (seconds) Slow (minutes)
Deterministic Sensitive to API state
No cost Cost per run
Unit tests use mock providers. The mock provider is a test-only provider that fakes the state from the real provider. The test verifies the configuration logic. The test does not call the cloud. The test is fast.
Plan tests use real providers. The test runs terraform plan against the module. The plan is verified against
assertions. The plan is not applied. The test is slow.
The test is sensitive to the cloud account’s state.
The right test mode depends on the question. The unit test answers: “Does the configuration compile, validate, and produce the expected resources?” The plan test answers: “Does the real provider accept the configuration?”
A unit test file
The unit test file is alongside the module source:
modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── tests/
└── network.tftest.hcl
# modules/network/tests/network.tftest.hcl
mock_provider "aws" {
mock_data "aws_availability_zones" {
defaults = {
names = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
}
run "creates_vpc" {
command = plan
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block did not match the input."
}
}
run "creates_three_subnets" {
command = plan
assert {
condition = length(aws_subnet.public) == 3
error_message = "Expected 3 public subnets, got ${length(aws_subnet.public)}."
}
assert {
condition = length(aws_subnet.private) == 3
error_message = "Expected 3 private subnets, got ${length(aws_subnet.private)}."
}
}
run "validation_rejects_bad_environment" {
command = plan
variables {
environment = "qa"
}
expect_failure {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
The file has three run blocks. Each run is an
isolated test. The mock_provider block declares the
mock provider. The assert blocks verify the expected
state. The expect_failure block verifies that a bad
input is rejected.
The run block
A run block is one test:
run "test_name" {
command = plan # or apply
module {
source = "./.." # the module under test; default is the parent directory
}
variables {
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
assert {
condition = <expression>
error_message = "Failed because..."
}
}
The command is either plan or apply. The module
block is the module under test. The default is the parent
directory, which is the convention for tests in the
tests/ subdirectory. The variables block overrides
the consumer’s variables. The assert blocks verify the
expected state.
The run block has these fields:
| Field | Purpose |
|---|---|
command | plan or apply |
module | The module under test |
variables | The variable values for this test |
assert | The verification conditions |
expect_failure | The expected validation failure |
state | The state input to the test |
The apply command is rare. It is used for tests that
need apply-time data. The plan command is the default.
Mock providers
The mock provider is the in-test replacement for a real
provider. The mock provider is declared in the .tftest.hcl
file:
mock_provider "aws" {
mock_resource "aws_vpc" {
defaults = {
id = "vpc-mock-1234"
cidr_block = "10.0.0.0/16"
arn = "arn:aws:ec2:us-east-1:123456789012:vpc/vpc-mock-1234"
}
}
mock_data "aws_availability_zones" {
defaults = {
names = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
}
The mock provider returns the declared values for any resource or data source the module reads. The mock provider does not call the cloud. The mock provider is deterministic. The mock provider is free.
The mock provider is not a substitute for an integration test. The mock provider cannot catch a schema change in the real provider. The mock provider cannot catch a provider bug. The mock provider catches configuration errors. The integration test catches cloud errors.
Test coverage
The discipline of test coverage for a production module:
| Test | What it covers |
|---|---|
| Default invocation | The module produces the expected resources with default variables |
| Required variables | The variables are surfaced correctly |
| Validation rejection | Bad inputs are rejected at plan time |
| Output stability | The outputs are present and have the expected types |
| Integration | The real provider accepts the configuration |
A module with these five tests is well-tested. A module without them is not.
Default invocation
A test that runs the module with the default variables and verifies the expected resources:
run "default_invocation" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
assert {
condition = aws_vpc.main.tags["Environment"] == "production"
error_message = "Environment tag was not applied to the VPC."
}
}
Validation rejection
A test that verifies a bad input is rejected:
run "rejects_bad_cidr" {
command = plan
variables {
vpc_cidr = "not-a-cidr"
}
expect_failure {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "vpc_cidr must be a valid IPv4 CIDR."
}
}
The expect_failure block is the opposite of assert.
The test passes when the condition is false. The test
verifies that the validation block rejects the bad input.
Output stability
A test that verifies the outputs are present and have the expected types:
run "outputs_are_stable" {
command = plan
assert {
condition = output.vpc_id != null
error_message = "vpc_id output was not set."
}
assert {
condition = length(output.public_subnet_ids) == 3
error_message = "public_subnet_ids did not contain 3 entries."
}
}
The output.* syntax accesses the module’s outputs. The
test verifies the outputs are present. The test catches
accidental rename or removal of outputs.
Integration test
A test that runs against the real provider:
# tests/integration.tftest.hcl
provider "aws" {
region = "us-east-1"
access_key = "mock"
secret_key = "mock"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
}
run "applies_cleanly" {
command = apply
variables {
vpc_cidr = "10.99.0.0/16"
environment = "test"
}
assert {
condition = aws_vpc.main.cidr_block == "10.99.0.0/16"
error_message = "VPC was not created with the expected CIDR."
}
}
The integration test runs against a real AWS account. The test creates real resources. The test is slow. The test costs money. The test is sensitive to the cloud account’s state.
The integration test is typically run on a schedule, not on every commit. The CI pipeline runs the unit tests on every commit. A nightly job runs the integration tests against a sandbox account.
Running the tests
The reader runs the test framework:
# Severity: READ-ONLY
terraform test
tests/network.tftest.hcl... in progress
run "creates_vpc"... pass
run "creates_three_subnets"... pass
run "validation_rejects_bad_environment"... pass
run "default_invocation"... pass
run "rejects_bad_cidr"... pass
run "outputs_are_stable"... pass
Success! 6 passed.
The exit code is zero. The CI pipeline runs the same command. The pipeline fails on a non-zero exit code.
# Severity: READ-ONLY
terraform test -verbose
tests/network.tftest.hcl... in progress
run "creates_vpc"... pass
aws_vpc.main.cidr_block == "10.0.0.0/16" PASS
run "creates_three_subnets"... pass
length(aws_subnet.public) == 3 PASS
length(aws_subnet.private) == 3 PASS
run "validation_rejects_bad_environment"... pass
expected failure: contains(["dev", "staging", "prod"], var.environment) PASS
Success! 3 passed.
The -verbose flag shows each assertion. The output is
the audit trail.
Wire the tests into CI
The tests are useless if they do not run. The CI pipeline runs the tests on every pull request:
# .github/workflows/test.yml
name: tests
on:
pull_request:
paths:
- 'modules/**'
- 'tests/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
- run: terraform test
The pipeline runs fmt, init, validate, and test.
The pipeline fails on any non-zero exit. The pipeline
prevents the merge of a module that does not pass its
tests.
Production failure modes
-
No test files. The module has no tests. The CI pipeline does not catch regressions. The fix is to add at least one test file per module.
-
Tests that only call
terraform validate. Thevalidatecommand does not run thevalidationblocks. The test passes even when validation is broken. The fix is to useterraform testwithcommand = plan. -
Mock provider that fakes the wrong shape. The mock returns a value that does not match the real provider’s schema. The unit test passes. The integration test fails. The fix is to run the integration test on a schedule.
-
Tests that depend on a real cloud account. The unit tests require credentials. The CI pipeline is fragile. The fix is to use mock providers for unit tests and to run integration tests on a schedule.
-
Tests that test the wrong thing. A test that asserts the VPC’s
idis a string is not a useful test. The fix is to assert on the variable’s effect: the CIDR, the tag, the count. -
Apply tests that leave resources behind. The test creates resources and does not destroy them. The cost accumulates. The fix is to use
command = planfor the majority of tests, and to use a sandbox account with a cleanup job for the integration tests.
Security implications
- The test runs as the CI pipeline’s identity. The integration test creates real resources. The CI pipeline has the credentials. The credentials are scoped to a sandbox account.
- The mock provider returns canned values. The canned
values should not contain real secrets. The mock
block can include a
stringfor testing, but the string should be obvious. - The test file is committed to the module repository. The test file is public if the module is public. The test file should not contain real ARNs, real account IDs, or real resource names.
Performance implications
- A unit test with mocks runs in seconds. The CI pipeline can run hundreds of unit tests in a single job.
- An integration test runs in minutes. The CI pipeline runs the integration tests on a schedule (nightly, not on every commit).
- The
terraform testcommand is parallel. The default is one run at a time. The-parallelismflag controls the parallelism.
What comes next
The next lesson is module abstraction: the right level of abstraction, the cost of over-abstraction, and the cost of under-abstraction.
Verification
-
terraform testexits zero for the module. - The module has at least one unit test file.
- The module has at least one test that asserts a validation rejection.
- The module has at least one test that asserts an output is present.
- The CI pipeline runs
terraform teston every pull request. - The integration tests run on a schedule (nightly).
Knowledge check · 6 questions
Q1. Which file extension is correct for a Terraform test file?
Q2. What is the role of a mock_provider block in a test file?
Q3. A unit test with a mock provider is sufficient to catch a real provider's schema change.
Q4. Which test verifies that a validation block rejects a bad input?
Q5. Which of the following are appropriate test coverage for a production module? (Select all that apply.)
Q6. A CI pipeline runs terraform test on every pull request. An integration test fails in 5% of runs against the sandbox account. What is the right fix?
Passing score: 75%. Answers are checked in this browser.