Testing Terraform and OpenTofu

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Infrastructure code is code, and like any code it breaks when somebody edits it. A renamed variable, a changed default or a provider upgrade can silently turn a private bucket into a public one, or delete a database on the next apply. Reading a plan catches some of that, but only when a person reads it carefully, every time. Tests make the same checks repeatable, and run them before a human has to look.

This page describes how to test Terraform 1.17 and OpenTofu 1.13 configurations and modules. It starts with the testing theory (what can be tested, at what cost, and which layer is the right one), then covers the native terraform test framework in depth, including mocks and overrides, then the Go library Terratest, the examples/ convention, LocalStack as a local cloud, and how to run all of it in CI. It follows Lifecycle, Validation and Checks, which explains the guardrails that live inside the configuration, and Module Development and Publishing, which explains the module layout that tests assume. It leads into Code Quality and Security Scanning, Policy as Code and CI/CD and Automation.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). The module under test runs entirely on the kreuzwerker/docker provider so that it can be exercised locally; the cloud examples are illustrative and use placeholders only. All commands and outputs are abbreviated and illustrative unless they are quoted from a linked official page.

Testing theory for infrastructure

What is different about testing infrastructure

Application tests are fast, deterministic and free: a function is called with arguments and the result is compared. An infrastructure test has three awkward properties.

  • It is slow. Creating a managed database or a load balancer takes minutes, and a test that creates real resources runs at that speed.

  • It costs money and leaves residue. Real resources are billed, and a test that crashes halfway can leave them behind.

  • The thing under test is partly outside the repository. The real behaviour of an aws_s3_bucket is defined by the cloud API, not by the provider schema. Terraform can prove that it asked for the right thing; only the cloud can prove that the right thing was delivered.

Good infrastructure testing therefore means putting each check at the cheapest layer that can detect the defect.

The layers

The classic testing pyramid applies, with Terraform-specific names. Cheap layers are at the bottom and are run on every commit; expensive layers are at the top and run less often.

flowchart TB L4["Layer 4: production checks
check blocks, drift detection, continuous validation"] L3["Layer 3: end-to-end tests
real apply, real endpoint, Terratest or terraform test with apply"] L2["Layer 2: integration tests
apply against a local cloud such as LocalStack or a sandbox account"] L1["Layer 1: unit tests
terraform test with command = plan or mock_provider"] L0["Layer 0: static checks
fmt, validate, linters, security scanners, policy"] L0 --> L1 --> L2 --> L3 --> L4
Layer What it proves Tooling Cost

0. Static

The code is formatted, syntactically and type-correct, and follows rules (no public buckets, no wildcard IAM).

terraform fmt -check, terraform validate, linters and scanners, policy engines

Seconds, no credentials

1. Unit

Given these inputs, the configuration computes these values and rejects these inputs. Names, tags, counts, conditionals, for_each keys, custom conditions.

terraform test with command = plan, or with mock_provider

Seconds, no credentials

2. Integration

The resources can really be created and wired together, against an emulator or a sandbox.

terraform test with command = apply, LocalStack, Terratest

Minutes, little or no cost

3. End to end

The deployed system behaves correctly: an HTTP request returns 200, a database accepts a connection.

Terratest, or terraform test with a helper module that calls the endpoint

Minutes, real cost

4. Production checks

The running environment still matches the intent.

check blocks, terraform plan -detailed-exitcode on a schedule, HCP Terraform continuous validation

Continuous

The official tutorial describes native tests as "comparable to unit and integration testing", and contrasts them with the validation features (variable validation, preconditions, postconditions and check blocks), which check the infrastructure your configuration deploys, while tests check the configuration’s own behaviour and logic.

Which layer for which defect

  • A typo in a tag key, a wrong default, a missing count condition: layer 1. A plan-only test finds it in a second.

  • "Does this input combination get rejected?": layer 1 with expect_failures.

  • A defect that depends on how a provider fills computed attributes (an ARN used in a policy, a generated ID used in a name): layer 1 with a mock that supplies those attributes, or layer 2.

  • "Does the IAM policy really allow the application to read the bucket?": layer 3. Mocks cannot answer this.

  • "Did somebody change the bucket by hand last week?": layer 4.

Layer 0 is covered in Code Quality and Security Scanning and Policy as Code; the guardrails of layer 4 are covered in Lifecycle, Validation and Checks. The rest of this page is about layers 1 to 3.

The native test framework: terraform test

terraform test is built into the CLI since Terraform 1.6. It executes the .tftest.hcl files it finds, and for every run block it plans or applies a configuration, evaluates assertions against the result, and finally destroys everything it created. The tests run as a separate operation from plan and apply: they create ephemeral infrastructure and do not touch your existing state or resources.

Files and layout

A test file name ends in .tftest.hcl (or .tftest.json). By default terraform test loads test files from the root directory of the module and from a directory named tests; the -test-directory flag changes the directory, although the documentation advises against changing it. A root-level test file contains:

  • zero or one test block (file-level settings, currently parallel),

  • one or more run blocks,

  • zero or one variables block,

  • zero or more provider blocks.

The order does not matter, but defining variables and provider blocks first is recommended. Because tests need the providers and modules the configuration uses, run terraform init before terraform test, and again whenever a provider or module is added.

A typical module layout:

modules/bookshelf-app/
  main.tf
  variables.tf
  outputs.tf
  versions.tf
  tests/
    unit.tftest.hcl          # plan-only and mocked: fast, no Docker daemon
    integration.tftest.hcl   # apply against a real Docker daemon
    setup/
      main.tf                # helper module used by the integration test
examples/
  basic/
    main.tf

The module under test

The tests below exercise a small Bookshelf module that runs catalog-api and reviews-api containers and a PostgreSQL container, and rejects an invalid environment name.

# modules/bookshelf-app/versions.tf
terraform {
  required_version = ">= 1.7"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

# modules/bookshelf-app/variables.tf
variable "environment" {
  type        = string
  description = "Deployment environment."

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

variable "replicas" {
  type        = number
  default     = 1
  description = "Number of catalog-api containers."

  validation {
    condition     = var.replicas >= 1 && var.replicas <= 5
    error_message = "replicas must be between 1 and 5."
  }
}

variable "catalog_image" {
  type    = string
  default = "bookshelf/catalog-api:1.4.0"
}

# modules/bookshelf-app/main.tf
resource "docker_container" "catalog" {
  count = var.replicas

  name  = "catalog-api-${var.environment}-${count.index}"
  image = var.catalog_image

  env = [
    "BOOKSHELF_ENV=${var.environment}",
  ]
}

resource "docker_container" "db" {
  name  = "postgres-${var.environment}"
  image = "postgres:16"
}

# modules/bookshelf-app/outputs.tf
output "catalog_names" {
  value = docker_container.catalog[*].name
}

Run blocks

Each run block is one step. Run blocks execute in the order they appear in the file, and by default every step reuses the state produced by the previous steps, so a file reads like a short script: create the thing, check it, change an input, check again. Every run block has a label, which names it in the output.

Argument Meaning

command

apply (the default) or plan. A plan run creates no infrastructure.

plan_options

A nested block with mode (normal, the default, or refresh-only), refresh (boolean, default true), replace and target (lists of resource addresses in the configuration under test).

variables

Variable values for this run. They override the file-level variables block.

module

Run an alternate module instead of the one under test. Supports only source (local or registry) and version.

providers

Map the providers the run uses, for example aws.secondary = aws.tertiary.

assert

One or more nested blocks, each with a condition and an error_message.

expect_failures

A list of checkable objects that are expected to fail.

state_key

Which internal state file the run uses (see below).

parallel

Boolean, default false. Allows the run to execute concurrently with others.

A first unit test with command = plan

A plan run needs no infrastructure and no daemon for resources that are only planned, which makes it the workhorse of unit testing. The assertions can reference any named value in the configuration, plus the outputs of the current and of previous run blocks.

# modules/bookshelf-app/tests/unit.tftest.hcl
variables {
  environment = "staging"
  replicas    = 2
}

run "names_include_the_environment" {
  command = plan

  assert {
    condition     = docker_container.catalog[0].name == "catalog-api-staging-0"
    error_message = "catalog container names must start with catalog-api-<environment>"
  }

  assert {
    condition     = length(docker_container.catalog) == 2
    error_message = "expected one catalog container per replica"
  }
}

run "environment_is_passed_to_the_container" {
  command = plan

  assert {
    condition     = contains(docker_container.catalog[0].env, "BOOKSHELF_ENV=staging")
    error_message = "BOOKSHELF_ENV must carry the environment name"
  }
}
$ terraform init
$ terraform test
tests/unit.tftest.hcl... in progress
  run "names_include_the_environment"... pass
  run "environment_is_passed_to_the_container"... pass
tests/unit.tftest.hcl... tearing down
tests/unit.tftest.hcl... pass

Success! 2 passed, 0 failed.

The console output above is illustrative; the format follows the tutorial, which reports each run block as pass or fail and ends with a summary such as Success! 2 passed, 0 failed. A failed assertion prints the failing condition, the actual values and the error_message.

Plan or apply

A plan run can only check values known at plan time. Anything computed by the provider (an ID, an ARN, a generated address) appears as (known after apply) and cannot be asserted. When an assertion needs such a value, use command = apply (a real or emulated provider) or a mock that generates the value. An apply run is also the only way to check that the provider accepts the configuration, since provider-side validation happens during apply.

Testing that bad input is rejected: expect_failures

The expect_failures argument lists the checkable objects that must fail, which turns a custom condition into a test subject. The run passes only if the listed objects fail and nothing else does.

run "unknown_environment_is_rejected" {
  command = plan

  variables {
    environment = "qa"
  }

  expect_failures = [
    var.environment,
  ]
}

run "too_many_replicas_are_rejected" {
  command = plan

  variables {
    environment = "dev"
    replicas    = 9
  }

  expect_failures = [
    var.replicas,
  ]
}

The documented rules to remember:

  • Only user-defined custom conditions can be expected to fail: variable validation, precondition, postcondition and check blocks. A type mismatch still fails the test, because it is not a custom condition.

  • Except for check blocks, a failure halts evaluation, so only one checkable object can be reliably expected per run block. Use one run block per rule, as above.

  • With command = apply, a failure at plan time still fails the run. The documentation recommends using expect_failures mainly with command = plan.

A check block never fails a plan or apply; it only warns. Inside a test, a failing check can be listed in expect_failures, and an unexpected one is reported. See Lifecycle, Validation and Checks for the guardrails themselves.

Variables in tests

A variables block at the root of a test file applies to every run block in that file. A variables block inside a run block overrides it for that run. Values set in test files have the highest precedence: they override environment variables (TF_VAR_), variable files and command line input. Automatic variable files (terraform.tfvars, *.auto.tfvars) found in a test directory apply only to the tests in that directory.

Run-level variables can reference the outputs of earlier runs (written run.NAME.OUTPUT); file-level variables can only reference global variables. This is how a setup step feeds the real step:

run "setup" {
  module {
    source = "./tests/setup"
  }
}

run "uses_the_setup_output" {
  command = plan

  variables {
    environment = run.setup.environment
  }

  assert {
    condition     = docker_container.db.name == "postgres-${run.setup.environment}"
    error_message = "database container name must follow the environment"
  }
}

Providers in tests

Root-level provider blocks in a test file are passed into the configuration for every run. Without them the provider default configuration applies. Aliased providers are supported, and a run block can remap them with its providers argument. Since Terraform 1.7, provider blocks can reference test variables and the outputs of earlier runs, which is how a setup run that creates credentials can feed the provider used by later runs.

provider "docker" {
  host = var.docker_host
}

variables {
  docker_host = "unix:///var/run/docker.sock"
}

Switching providers between apply runs can fail, because resources created with one provider may not be manageable by another.

Alternate modules: setup and loader modules

A run block can replace the module under test with another one through its module block, which supports only source (a local path or a registry address) and version. Two patterns are documented:

  • A setup module creates the prerequisites the module under test needs (a network, a random name prefix).

  • A loader module reads existing infrastructure, for example with data sources, so that a later assertion can check what was really deployed. This is how the tutorial checks a website: a helper module makes an HTTP GET request and a run block asserts that the status is 200.

# modules/bookshelf-app/tests/integration.tftest.hcl
run "setup" {
  module {
    source = "./tests/setup"
  }
}

run "deploy" {
  variables {
    environment = run.setup.environment
    replicas    = 1
  }

  assert {
    condition     = length(output.catalog_names) == 1
    error_message = "one catalog container expected"
  }
}
# modules/bookshelf-app/tests/setup/main.tf
resource "random_pet" "env" {
  length = 2
}

output "environment" {
  value = "dev"
}

output "suffix" {
  value = random_pet.env.id
}

State in a test file

Terraform keeps one internal state file for the main configuration, and one per alternate module. The state_key argument selects the state a run uses; giving the same state_key to several run blocks makes them share one state file, and giving different keys to runs of the same module keeps them independent. Everything is kept in memory and is destroyed at the end of the file.

Cleanup

At the end of a test file Terraform attempts to destroy all the resources it created, in the reverse order of the run blocks. The order matters when alternate modules depend on each other: the documentation’s example is an S3 bucket that still contains objects, which cannot be deleted until another module removes them. If a destroy fails, resources are left behind, so test in accounts and namespaces where residue can be found and swept.

Terraform 1.17 also lists, among the experiments that are only enabled in alpha builds and are not available in stable releases, backend blocks and skip_cleanup attributes in test files and a terraform test cleanup command. They are mentioned here only so that a later release does not surprise you; do not build on them.

Parallelism

By default run blocks execute one after another. Two settings change that.

  • parallel = true in a run block (or in the file-level test block, where the run’s own setting overrides it) lets the run execute concurrently.

  • Runs execute in parallel only if they do not reference each other’s outputs, do not share a state file (use distinct state_key values when they run the same module), and all have parallel = true.

  • A run with parallel = false is a synchronisation point: all earlier runs finish first, and later runs wait for it.

test {
  parallel = true
}

run "dev_defaults" {
  command   = plan
  state_key = "dev"

  variables {
    environment = "dev"
  }

  assert {
    condition     = length(docker_container.catalog) == 1
    error_message = "dev runs a single replica by default"
  }
}

run "prod_scale" {
  command   = plan
  state_key = "prod"

  variables {
    environment = "prod"
    replicas    = 3
  }

  assert {
    condition     = length(docker_container.catalog) == 3
    error_message = "prod runs three replicas"
  }
}

Two CLI flags limit the concurrency: -run-parallelism=<n> sets how many run blocks execute in parallel within a single test file (default 10), and -parallelism=<n> sets how many plan or apply operations run in parallel within a single run block (default 10).

Command line flags

Flag Purpose

-filter=testfile

Limit the run to the given test files.

-json

Print machine-readable JSON results.

-junit-xml=<path>

Save a JUnit XML report to the file. The path can be relative or absolute. Currently incompatible with -cloud-run.

-test-directory=<dir>

Override the default tests directory. Test files in the root directory are always loaded.

-verbose

Print the plan or the state for each run block, depending on its command.

-parallelism=<n>

Operations in parallel within one run block (default 10).

-run-parallelism=<n>

Run blocks in parallel within one test file (default 10).

-cloud-run=<module source>

Run the tests remotely in HCP Terraform against a module in the private registry. Requires terraform login; public registry modules are not supported.

Output formats: JSON and JUnit XML

Humans read the default output. Machines read -json and -junit-xml. The JUnit XML report is what most CI systems turn into a test summary page, a failed-test list and a history chart.

terraform test -junit-xml=reports/terraform-test.xml
terraform test -json > reports/terraform-test.json
terraform test -verbose -filter=tests/unit.tftest.hcl

Mocking providers and overriding values

Applying real resources makes a test slow and demands credentials. Since Terraform 1.7, a test can replace providers, single resources, data sources or whole modules with fabricated data, which turns an apply-based test into a fast unit test that needs no Docker daemon, no cloud account and no network.

Mocks are about speed and isolation, not about truth. A mocked test proves that the configuration’s own logic is right (names, counts, conditions, how outputs are derived). It cannot prove that a provider accepts the arguments or that a policy works; keep a handful of real applies for that.

mock_provider

A mock_provider block replaces a provider. It returns the same schema as the real provider, so invalid arguments are still rejected at validation. For resources and data sources it sets the values from the configuration and generates fake data for the computed attributes.

mock_provider "docker" {}

run "plan_with_a_mocked_provider" {
  command = apply

  variables {
    environment = "dev"
  }

  assert {
    condition     = length(docker_container.catalog) == 1
    error_message = "one container expected"
  }
}

Generation rules for computed attributes without an explicit value, as documented:

  • numbers are 0 and booleans are false,

  • strings are a random 8 character alphanumeric string,

  • sets, lists and maps are empty collections,

  • objects get their required sub-attributes generated recursively by the same rules,

  • only computed values are generated, so required attributes must still be set by the configuration.

By default the generated values appear during apply, and a plan shows them as (known after apply).

mock_resource, mock_data and override_during

When the random defaults are not good enough (an ARN must look like an ARN, a computed address must be parseable), set explicit defaults per resource or data source type.

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::bookshelf-covers-test"
    }
  }

  mock_data "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::bookshelf-covers-test"
    }
  }
}

override_during accepts plan or apply. With override_during = plan, the generated values are available already at plan time, which lets a plan-only test assert on them. It can be set on the mock_provider block, and a value set on a mock_resource or mock_data block overrides the one on the provider.

mock_provider "aws" {
  override_during = plan
}

Sharing mock data: tfmock files

Mock data can live in files with the extension .tfmock.hcl or .tfmock.json, shared by several tests, and loaded with the source argument of mock_provider, which names a directory. If a directly nested block and a sourced file define the same resource or data source, the nested block takes precedence.

# testing/aws/data.tfmock.hcl
mock_resource "aws_s3_bucket" {
  defaults = {
    arn = "arn:aws:s3:::bookshelf-covers-test"
  }
}

# tests/covers.tftest.hcl
mock_provider "aws" {
  source = "./testing/aws"
}

override_resource, override_data and override_module

Overrides replace a single object, whether the provider is real or mocked, and they do not call the underlying provider.

  • override_resource and override_data take a target (a resource or data source address) and values.

  • override_module takes a target (a module call) and outputs. The module is not planned or applied at all, which is the standard way to cut a heavy dependency (a network module) out of the test of its consumer.

  • All three can be written at the root of a test file or inside a run block, and the first two can also be nested in mock_provider blocks and in mock data files.

mock_provider "docker" {}

override_module {
  target = module.network
  outputs = {
    network_name = "bookshelf-test"
  }
}

run "app_joins_the_network" {
  command = plan

  assert {
    condition     = module.network.network_name == "bookshelf-test"
    error_message = "the overridden network output must be visible"
  }
}

Ephemeral resource mocks (Terraform 1.17)

Ephemeral resources (secrets read from a vault, short-lived tokens) never reach the plan or the state, and before Terraform 1.17 a mocked provider could not stand in for them: in Terraform 1.10 a forum thread reports the error "No ephemeral resource types in mock providers". The Terraform 1.17.0 changelog (21 October 2026) lists "test: Add mock_provider support for ephemeral resources" among the enhancements, so a configuration that reads an ephemeral resource (for example a database password from a secrets manager) can now be tested with a mocked provider and no secrets backend. The 1.17 mocking documentation page does not yet describe a dedicated block for them, so check the changelog entry and the pull request it links to for the exact syntax before relying on a particular form. See Sensitive Data, Ephemeral and Write-only for what ephemeral resources are.

A complete unit-test file with mocks

Putting the pieces together, the following file tests the module without Docker: the provider is mocked, a plan-time check uses override_during, and the validation rules are tested with expect_failures.

# modules/bookshelf-app/tests/mocked.tftest.hcl
mock_provider "docker" {
  mock_resource "docker_container" {
    defaults = {
      id = "mock-container-id"
    }
  }
}

variables {
  environment = "prod"
  replicas    = 3
}

run "prod_runs_three_replicas" {
  command = apply

  assert {
    condition     = length(output.catalog_names) == 3
    error_message = "prod must run three catalog containers"
  }

  assert {
    condition     = alltrue([for n in output.catalog_names : startswith(n, "catalog-api-prod-")])
    error_message = "every name must carry the environment"
  }
}

run "zero_replicas_are_rejected" {
  command = plan

  variables {
    replicas = 0
  }

  expect_failures = [
    var.replicas,
  ]
}

Terratest

Terratest is a Go library from Gruntwork for testing infrastructure code. Unlike terraform test, the test is an ordinary Go test: it can start any process, call any API, retry, parse JSON, open an SSH connection or issue an HTTP request, and use the whole Go ecosystem. That makes it the right tool for layer 3 tests that need real behaviour (does the endpoint answer, does the database accept the application user). The cost is a second language and a Go toolchain; the Terratest quick-start lists a Go version requirement of 1.26 or later, and the go.mod on the main branch declares go 1.26.0.

The shape of a Terratest test

A Terratest test builds a terraform.Options value (where the code is, which variables to pass, which binary to run), registers the destroy with defer, applies, reads outputs and asserts on them.

package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestBookshelfBasicExample(t *testing.T) {
	t.Parallel()

	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../examples/basic",
		Vars: map[string]interface{}{
			"environment": "dev",
			"replicas":    1,
		},
		NoColor: true,
	})

	// Always clean up, even when an assertion fails.
	defer terraform.Destroy(t, opts)

	terraform.InitAndApply(t, opts)

	first := terraform.OutputRequired(t, opts, "first_catalog_name")
	assert.Equal(t, "catalog-api-dev-0", first)
}

The pieces, as verified against the package documentation and the options.go source:

  • terraform.Options has, among others, TerraformDir (the folder with the code), Vars (a map passed as -var), VarFiles (passed as -var-file), EnvVars, NoColor, TerraformBinary (the executable name), PlanFilePath, RetryableTerraformErrors with MaxRetries, Reconfigure and Upgrade.

  • terraform.InitAndApply, terraform.Destroy, terraform.Output and terraform.OutputRequired run the corresponding commands; WithDefaultRetryableErrors returns a copy of the options that retries on a list of known transient errors.

  • Setting TerraformBinary: "tofu" makes the same helper functions run OpenTofu instead; the function names do not change.

Practical rules for Terratest

  • Always defer terraform.Destroy. The quick-start explains that the deferred destroy runs whether the test succeeds or fails.

  • Raise the test timeout: go test -v -timeout 30m. Go’s default of 10 minutes can abort a test with a panic that skips the deferred destroy and leaves resources behind.

  • Use t.Parallel() and give each test its own unique resource names (a random suffix) so tests can run side by side.

  • Do not point several parallel tests at the same folder, since terraform init writes into it; give each test its own copy of the code.

  • Run the tests in a dedicated sandbox account or namespace, with a sweeper job that deletes anything older than a day.

When to prefer which

Question terraform test Terratest

Language

HCL, no extra toolchain

Go

Mocks and plan-only checks

Built in

Not built in; use real plans

Calling an HTTP endpoint, SSH, a database

Through a helper module and a data source

Natural, with retries

Retrying flaky operations

Not built in

RetryableTerraformErrors with MaxRetries

Setup and teardown

Automatic, in reverse order

Written by hand with defer

JUnit XML

-junit-xml

Through Go tooling that converts go test -json

OpenTofu

tofu test

TerraformBinary: "tofu"

A common split is: terraform test for layers 1 and 2 in every pull request, Terratest for a few layer 3 scenarios on a schedule or before a release.

Testing modules from examples/

A reusable module should be tested the way a consumer uses it. The standard module structure puts runnable examples under examples/ at the root of the repository, with each example optionally carrying its own README, and says that module blocks in an example should set source to the address an external caller would use. That convention doubles as a test fixture: each example is a minimal, realistic root module, and a test applies it.

terraform-docker-bookshelf-app/
  main.tf
  variables.tf
  outputs.tf
  tests/
    unit.tftest.hcl
  examples/
    basic/
      main.tf          # module "app" with defaults
    prod-like/
      main.tf          # module "app" with replicas = 3
  test/
    basic_test.go      # Terratest applies examples/basic
# examples/basic/main.tf
terraform {
  required_version = ">= 1.7"
}

module "app" {
  # In a published module the source is the registry address a consumer would use;
  # the relative path keeps the example runnable from a checkout.
  source = "../../"

  environment = "dev"
}

output "catalog_names" {
  value = module.app.catalog_names
}

output "first_catalog_name" {
  value = module.app.catalog_names[0]
}

Two ways to test an example:

  • With terraform test, a run block’s module block can point at the example directory:

    run "example_applies" {
      module {
        source = "./examples/basic"
      }
    
      assert {
        condition     = length(output.catalog_names) == 1
        error_message = "the basic example must create one catalog container"
      }
    }
  • With Terratest, as in the previous section (TerraformDir: "../examples/basic").

An example doubles as living documentation, and a test that applies it catches the case where a module change breaks the README snippet. The publishing side of this layout is described in Module Development and Publishing.

LocalStack as a local cloud

LocalStack emulates many AWS services on a single local endpoint, port 4566 by default. For tests it sits between a mock and a real account: the AWS provider really makes API calls, so provider-side argument validation, dependency ordering and error handling are exercised, but nothing is billed and nothing leaks. Its fidelity is partial: some services and features are missing or behave differently, so treat a LocalStack pass as evidence for layer 2, not as proof for layer 3.

Start it with Docker (the image is large; this is an ordinary docker run, shown for reference):

docker run --rm -d --name localstack -p 127.0.0.1:4566:4566 localstack/localstack

Then point the AWS provider at it. The official LocalStack guide uses test credentials, turns off the credential validation, and lists the service endpoints; for S3 it recommends the dedicated hostname, and s3_use_path_style = true with localhost when that name cannot be resolved.

provider "aws" {
  region                      = "us-east-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_requesting_account_id  = true
  s3_use_path_style           = true

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

resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-test"
}

The values test and test are the conventional dummy credentials for the emulator and are not secrets. Never reuse this provider block for a real account; keep it in the test directory or behind a variable so that production code does not carry emulator endpoints.

The LocalStack guide also documents wrapper tooling that configures the endpoints automatically. It states that the lstk terraform command supersedes the older tflocal wrapper script, which is deprecated but still available.

LocalStack in a native test

A test file can carry its own provider block, so the module stays free of emulator settings:

# tests/covers.tftest.hcl
provider "aws" {
  region                      = "us-east-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_requesting_account_id  = true
  s3_use_path_style           = true

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

run "covers_bucket_is_created" {
  command = apply

  assert {
    condition     = aws_s3_bucket.covers.bucket == "bookshelf-covers-test"
    error_message = "bucket name must match"
  }
}

A remote state backend on LocalStack is shown in Backends and Remote State.

Running tests in CI

A sensible pipeline runs the cheap layers on every push and pull request, and the expensive ones on a schedule or on a protected branch. The workflow below installs Terraform with the official setup action, runs the static checks and the unit tests, writes a JUnit report, and starts LocalStack as a service container for the integration tests. The setup action’s README currently shows hashicorp/setup-terraform@v4; older tutorials still use v3, so use the major shown in the action’s README when you copy this.

name: terraform-tests

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.17.0"
      - name: Format and validate
        run: |
          terraform fmt -check -recursive
          terraform init -backend=false
          terraform validate
      - name: Unit tests
        run: terraform test -filter=tests/unit.tftest.hcl -junit-xml=reports/unit.xml

  integration:
    runs-on: ubuntu-latest
    needs: unit
    services:
      localstack:
        image: localstack/localstack
        ports:
          - 4566:4566
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.17.0"
      - name: Integration tests against LocalStack
        run: |
          terraform init
          terraform test -filter=tests/covers.tftest.hcl -junit-xml=reports/integration.xml

Points to observe:

  • terraform init -backend=false avoids needing state credentials for a module that declares a remote backend during the static checks.

  • The wrapper installed by setup-terraform (terraform_wrapper, default true) exposes stdout, stderr and exitcode as step outputs; turn it off when a later step parses the raw CLI output, for example the JSON from terraform test -json.

  • Do not put real credentials in the repository. For real-cloud integration tests, authenticate the job with OIDC federation and short-lived tokens, as described in CI/CD and Automation.

  • Pin the Terraform version with terraform_version so that a new release does not change test behaviour between runs, and run a second job against the newest release to see breakage early.

  • A Terratest job is an ordinary Go job: actions/setup-go, then go test -v -timeout 30m ./test/..., with the sandbox credentials supplied through OIDC.

Publishing the JUnit XML to a pull-request summary is done with a test-reporter action of your choice; the XML file is the stable interface.

Terraform and OpenTofu

OpenTofu has its own tofu test with the same file format, run blocks, assert, expect_failures, mock_provider and overrides. The differences documented on the OpenTofu page are:

  • OpenTofu also loads .tofutest.hcl and .tofutest.json files. When a .tftest and a .tofutest file share a base name in the same directory, only the .tofutest file is loaded, which lets a module ship separate tests for the two tools.

  • -json-into=out.json writes the same output as -json to a file, so one run can produce both the human and the machine readable form.

  • override_module cannot target a single instance of a module call that uses count or for_each; since OpenTofu 1.12 overrides can target individual instances, or all of them with [*].

  • command = plan runs do not populate run state, so later run blocks cannot reference their outputs; use command = apply for runs that produce values.

  • expect_failures only covers custom conditions in your configuration, not failures raised by provider validation.

  • Module sources, backend configuration and encryption blocks that use variables need root module variable values when running tofu test.

The OpenTofu documentation page does not describe a JUnit XML flag; check tofu test -help for your version before relying on one, or convert the JSON output.

What changed recently

Version Change

Terraform 1.6

terraform test and .tftest.hcl files introduced; the framework requires 1.6.0 or later.

Terraform 1.7

mock_provider, mock_resource, mock_data and the override_* blocks; provider blocks may reference test variables and run outputs.

Terraform 1.17

mock_provider supports ephemeral resources (changelog, 21 October 2026). backend blocks, skip_cleanup and terraform test cleanup appear only as alpha-build experiments.

OpenTofu 1.12

override_resource, override_data and override_module can target individual for_each or count instances.

Common pitfalls

Symptom Cause and remedy

An assertion on an ID or ARN fails in a plan-only run

The value is unknown until apply. Use command = apply with a mock, set override_during = plan, or provide a default through mock_resource.

expect_failures run fails with an extra error

Two checkable objects failed in one run, or the failure is not a custom condition. Use one run block per rule.

Mocked test passes, real apply fails

Mocks do not validate arguments against the cloud. Keep a few real or LocalStack applies.

Parallel runs interfere

They share a state file or reference each other’s outputs. Give distinct state_key values or drop parallel.

Resources left behind after a failed test

The destroy at the end of the file failed. Sweep the sandbox; in Terratest, raise the timeout and keep defer.

terraform test cannot find a provider

terraform init was not run after a provider or module was added.

Emulator endpoints leaked into production code

The LocalStack provider block lives in the module. Move it into the test file or a variable.