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_bucketis 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.
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). |
|
Seconds, no credentials |
1. Unit |
Given these inputs, the configuration computes these values and rejects these inputs. Names, tags, counts, conditionals, |
|
Seconds, no credentials |
2. Integration |
The resources can really be created and wired together, against an emulator or a sandbox. |
|
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 |
Minutes, real cost |
4. Production checks |
The running environment still matches the intent. |
|
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
countcondition: 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
testblock (file-level settings, currentlyparallel), -
one or more
runblocks, -
zero or one
variablesblock, -
zero or more
providerblocks.
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 |
|---|---|
|
|
|
A nested block with |
|
Variable values for this run. They override the file-level |
|
Run an alternate module instead of the one under test. Supports only |
|
Map the providers the run uses, for example |
|
One or more nested blocks, each with a |
|
A list of checkable objects that are expected to fail. |
|
Which internal state file the run uses (see below). |
|
Boolean, default |
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,postconditionandcheckblocks. A type mismatch still fails the test, because it is not a custom condition. -
Except for
checkblocks, 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 usingexpect_failuresmainly withcommand = 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 = truein a run block (or in the file-leveltestblock, 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_keyvalues when they run the same module), and all haveparallel = true. -
A run with
parallel = falseis 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 |
|---|---|
|
Limit the run to the given test files. |
|
Print machine-readable JSON results. |
|
Save a JUnit XML report to the file. The path can be relative or absolute. Currently incompatible with |
|
Override the default |
|
Print the plan or the state for each run block, depending on its |
|
Operations in parallel within one run block (default 10). |
|
Run blocks in parallel within one test file (default 10). |
|
Run the tests remotely in HCP Terraform against a module in the private registry. Requires |
Source: terraform test command reference
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
Source: terraform test command reference
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_resourceandoverride_datatake atarget(a resource or data source address) andvalues. -
override_moduletakes atarget(a module call) andoutputs. 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_providerblocks 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.
Source: Terraform 1.17 changelog
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)
}
Source: Terratest quick start
The pieces, as verified against the package documentation and the options.go source:
-
terraform.Optionshas, 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,RetryableTerraformErrorswithMaxRetries,ReconfigureandUpgrade. -
terraform.InitAndApply,terraform.Destroy,terraform.Outputandterraform.OutputRequiredrun the corresponding commands;WithDefaultRetryableErrorsreturns 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 initwrites 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.
Source: Terratest quick start
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 |
|
Setup and teardown |
Automatic, in reverse order |
Written by hand with |
JUnit XML |
|
Through Go tooling that converts |
OpenTofu |
|
|
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]
}
Source: Standard module structure
Two ways to test an example:
-
With
terraform test, a run block’smoduleblock 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"
}
Source: LocalStack Terraform integration
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
Source: hashicorp/setup-terraform
Points to observe:
-
terraform init -backend=falseavoids needing state credentials for a module that declares a remote backend during the static checks. -
The wrapper installed by
setup-terraform(terraform_wrapper, defaulttrue) exposesstdout,stderrandexitcodeas step outputs; turn it off when a later step parses the raw CLI output, for example the JSON fromterraform 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_versionso 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, thengo 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.
Source: terraform test command reference
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.hcland.tofutest.jsonfiles. When a.tftestand a.tofutestfile share a base name in the same directory, only the.tofutestfile is loaded, which lets a module ship separate tests for the two tools. -
-json-into=out.jsonwrites the same output as-jsonto a file, so one run can produce both the human and the machine readable form. -
override_modulecannot target a single instance of a module call that usescountorfor_each; since OpenTofu 1.12 overrides can target individual instances, or all of them with[*]. -
command = planruns do not populate run state, so later run blocks cannot reference their outputs; usecommand = applyfor runs that produce values. -
expect_failuresonly 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.
Source: OpenTofu: tofu test
What changed recently
| Version | Change |
|---|---|
Terraform 1.6 |
|
Terraform 1.7 |
|
Terraform 1.17 |
|
OpenTofu 1.12 |
|
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 |
|
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 |
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 |
|
|
Emulator endpoints leaked into production code |
The LocalStack provider block lives in the module. Move it into the test file or a variable. |