Skip to main content
RunBook Academy

TerraformXIV · Modules: Reusable Building BlocksProduction Terraform

Testing Modules with terraform test

Intermediate⏱ ~16 minbashterraform

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

Not yet marked complete on this device.

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:

FieldPurpose
commandplan or apply
moduleThe module under test
variablesThe variable values for this test
assertThe verification conditions
expect_failureThe expected validation failure
stateThe 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:

TestWhat it covers
Default invocationThe module produces the expected resources with default variables
Required variablesThe variables are surfaced correctly
Validation rejectionBad inputs are rejected at plan time
Output stabilityThe outputs are present and have the expected types
IntegrationThe 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

  1. 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.

  2. Tests that only call terraform validate. The validate command does not run the validation blocks. The test passes even when validation is broken. The fix is to use terraform test with command = plan.

  3. 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.

  4. 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.

  5. Tests that test the wrong thing. A test that asserts the VPC’s id is a string is not a useful test. The fix is to assert on the variable’s effect: the CIDR, the tag, the count.

  6. Apply tests that leave resources behind. The test creates resources and does not destroy them. The cost accumulates. The fix is to use command = plan for 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 string for 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 test command is parallel. The default is one run at a time. The -parallelism flag 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 test exits 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 test on every pull request.
  • The integration tests run on a schedule (nightly).

Knowledge check · 6 questions

  1. Q1. Which file extension is correct for a Terraform test file?

  2. Q2. What is the role of a mock_provider block in a test file?

  3. Q3. A unit test with a mock provider is sufficient to catch a real provider's schema change.

  4. Q4. Which test verifies that a validation block rejects a bad input?

  5. Q5. Which of the following are appropriate test coverage for a production module? (Select all that apply.)

  6. 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.