Terraform Lesson 32 of 89

Testing Terraform for Real: Native terraform test, Terratest, and Policy Checks in CI

In a nutshell

Think of a Terraform change the way a buyer thinks about a house. A plan you skim before apply is a quick walk-through: it tells you the doors open and the walls are roughly where the drawing says. It does not tell you the wiring is to code, the roof does not leak, or that a previous owner quietly sealed off a room that was meant to stay open. For that you hire an inspector who actually tests things — and you do it before you hand over the keys.

Testing Terraform is that inspection. You build it in layers, cheapest first: quick static checks that run every time you save, plan-and-mock unit tests that exercise your module’s logic without ever touching a cloud, a policy engine that refuses changes which break the rules, and — for the few things only reality can confirm — integration tests that stand up throwaway infrastructure, poke it, and tear it back down. The whole ladder runs in CI, so a bad change is caught by a linter in one second instead of by a customer at 2 a.m.

The mental shift for a beginner: a green plan is not a passing test. plan proves Terraform could build something; a test proves it built the right something and that it actually works. Those are two different questions, and the rest of this lesson is about answering the second one without going broke or waiting twenty minutes for feedback.

Level: Advanced · Time: ~26 min

Terraform testing pyramid: native tests vs Terratest

Left to right the suite runs cheapest-first — static checks and native terraform test need no cloud, a policy engine gates the plan JSON, and only the handful of behaviours you truly cannot fake reach Terratest’s ephemeral real-cloud layer, with each green stage unlocking the next until the PR can merge.

Before you start, you should be comfortable writing a module with variables, outputs, and for_each/count (see Modules: authoring, structure, inputs, outputs), reading a terraform plan diff, and running a command in CI. A reading knowledge of Go helps for the Terratest section but is not required to follow it.

After this lesson you can:

Most Terraform “tests” are a plan someone eyeballed before clicking apply. That catches syntax errors and nothing else. Real confidence comes from a layered suite: cheap checks that run on every save, integration tests that stand up throwaway infrastructure and assert it actually works, and policy gates that block non-compliant changes before they reach an environment. This guide builds that suite and wires it into CI.

The Terraform test pyramid

Think in layers, cheapest and fastest at the bottom. Each layer catches a different class of failure, and you run more of the cheap ones.

Layer Tool Speed What it catches
Validate terraform validate, fmt, tflint < 1s Syntax, style, deprecated usage
Plan assertions terraform test (command: plan) seconds Wrong attributes, bad conditionals, var wiring
Unit terraform test + mocked providers sub-second Module logic without touching a cloud
Integration Terratest (Go) minutes Real provisioning, real behavior
Policy OPA/Conftest or Sentinel on plan JSON seconds Compliance, security, cost guardrails

The discipline that matters: anything testable without a cloud account belongs in the bottom three layers. Reserve the slow, money-spending integration layer for the handful of behaviors you genuinely cannot verify any other way (a load balancer actually serves traffic, an IAM policy actually denies an action).

Why the shape is a pyramid and not a rectangle is pure economics. A static check costs a fraction of a second and zero cloud spend, so you can run thousands of them on every keystroke. An integration test costs minutes of wall-clock time, real money, and a sandbox with quota — so you run a handful, deliberately. Push a failure down the pyramid and you learn about it sooner, cheaper, and closer to the line of code that caused it. A useful rule of thumb: if a test can pass or fail without a cloud account, it must not use one. The happy modern twist is that mocked providers (covered below) pulled a lot of logic that once needed Terratest down into the free, sub-second tier — the expensive top of the pyramid keeps shrinking as native testing matures, and your job is to keep pushing checks downward.

Native testing with the terraform test framework

Since Terraform 1.6, terraform test is built into the CLI. Tests live in .tftest.hcl files. Each file contains run blocks; each run executes a plan or apply against your configuration and evaluates assert blocks. By default Terraform looks in the working directory and a tests/ subdirectory.

Here is a plan-only test for a module that derives a storage account name and tags. command = plan means nothing is created, so it is fast and free.

# tests/naming.tftest.hcl

variables {
  project     = "kloudvin"
  environment = "dev"
  location    = "eastus"
}

run "derives_storage_account_name" {
  command = plan

  assert {
    condition     = output.storage_account_name == "stkloudvindev"
    error_message = "Storage account name not derived correctly"
  }
}

run "applies_required_tags" {
  command = plan

  assert {
    condition     = output.tags["environment"] == "dev"
    error_message = "environment tag missing or wrong"
  }
}

Run it:

terraform init
terraform test

You can override variables per run block, and you can validate that bad input is rejected. The expect_failures argument asserts that a specific resource or variable validation check fails — useful for proving your validation blocks and preconditions work.

run "rejects_invalid_environment" {
  command = plan

  variables {
    environment = "not-a-real-env"
  }

  expect_failures = [
    var.environment,
  ]
}

Plan-time assertions only see known values. Attributes computed by the provider at apply time (an assigned IP, a generated ID) show up as unknown during plan, so you cannot assert on them with command = plan. Use a mocked provider or an apply run for those.

Module unit tests with mocked providers

Mock providers (Terraform 1.7+) let a run block execute as if real infrastructure were created, but every provider call returns generated fake data. No credentials, no network, sub-second runs. This is how you unit-test module logic — count math, for_each keys, conditional resource creation — at the speed of a plan.

Declare the mock in the test file with mock_provider. You can pin specific attributes with override_resource (or override_data for data sources) so assertions are deterministic instead of relying on randomly generated mock values.

# tests/subnets.tftest.hcl

mock_provider "azurerm" {}

variables {
  vnet_cidr   = "10.10.0.0/16"
  subnet_count = 3
}

run "creates_expected_subnet_count" {
  command = plan

  assert {
    condition     = length(azurerm_subnet.this) == 3
    error_message = "Expected 3 subnets to be planned"
  }
}

run "overridden_id_is_stable" {
  command = apply

  override_resource {
    target = azurerm_virtual_network.this
    values = {
      id = "/subscriptions/0000/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet"
    }
  }

  assert {
    condition     = output.vnet_id == "/subscriptions/0000/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet"
    error_message = "vnet_id output did not match overridden value"
  }
}

Because the provider is mocked, command = apply here never calls Azure — it walks the graph and produces planned/overridden values. That gives you apply-time outputs (which expose computed attributes) without the cost or latency. Mock everything you can; it keeps the feedback loop tight enough to run on every file save.

Integration testing with Terratest

Some things only a real cloud can tell you. Terratest is a Go library that runs your actual Terraform, then queries the live resources and asserts on them, then tears everything down. The pattern is always the same: InitAndApply, assert, defer Destroy.

Put tests in a test/ directory as a Go module. A minimal integration test against an example fixture:

// test/network_test.go
package test

import (
	"testing"

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

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

	opts := &terraform.Options{
		TerraformDir: "../examples/hub-spoke",
		Vars: map[string]interface{}{
			"environment": "test",
			"location":    "eastus",
		},
	}

	defer terraform.Destroy(t, opts)

	terraform.InitAndApply(t, opts)

	vnetID := terraform.Output(t, opts, "vnet_id")
	assert.NotEmpty(t, vnetID)

	subnetIDs := terraform.OutputList(t, opts, "subnet_ids")
	assert.Len(t, subnetIDs, 3)
}

The defer terraform.Destroy runs even if an assertion fails, which is what keeps you from leaking resources. Always isolate state — give each run a unique workspace or backend key, and randomize resource names so parallel runs never collide:

import "github.com/gruntwork-io/terratest/modules/random"

uniqueID := random.UniqueId()
opts.Vars["name_suffix"] = uniqueID

Run the suite with the standard Go test runner. Integration tests are slow, so give them a generous timeout — Go’s default is 10 minutes and will kill a half-finished apply mid-flight, orphaning resources.

cd test
go test -v -timeout 45m -run TestHubSpokeNetwork

Asserting real behavior

Checking that an output is non-empty is weak. The point of paying for real infrastructure is to verify it behaves. Terratest ships helpers for exactly this.

For an HTTP endpoint, retry until it is healthy — freshly provisioned resources are rarely ready the instant apply returns:

import (
	"time"
	"github.com/gruntwork-io/terratest/modules/http-helper"
)

url := terraform.Output(t, opts, "app_url")

http_helper.HttpGetWithRetry(
	t, url, nil,
	200, "OK",
	30,             // retries
	10*time.Second, // sleep between retries
)

For anything past a simple GET, drop to the cloud SDK and assert on the resource directly. Terratest has provider modules (for example modules/azure, modules/aws), or you can call the vendor SDK yourself. The retry/backoff helpers generalize to any flaky check:

import "github.com/gruntwork-io/terratest/modules/retry"

retry.DoWithRetry(t, "wait for blob to be readable", 20, 15*time.Second,
	func() (string, error) {
		// call the storage SDK; return an error to trigger a retry
		return checkBlobExists(storageAccount, container, blob)
	},
)

The rule: never time.Sleep and hope. Always poll with a bounded retry so a slow-but-eventually-correct resource passes and a genuinely broken one fails fast.

Policy-as-code gates on the plan JSON

Tests verify behavior; policy verifies intent — “no public storage,” “every resource is tagged with a cost center,” “no VM larger than this SKU.” Both OPA/Conftest and HashiCorp Sentinel evaluate a machine-readable plan, so you gate the change before it is applied.

Generate the plan JSON once and feed it to your policy engine:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

A Conftest policy in Rego that fails the plan if any storage account allows public access. resource_changes is the stable, documented surface of the plan JSON — walk it rather than the internal planned_values tree.

# policy/storage.rego
package main

deny[msg] {
	resource := input.resource_changes[_]
	resource.type == "azurerm_storage_account"
	resource.change.after.public_network_access_enabled == true
	msg := sprintf("Storage account '%s' must not allow public network access", [resource.address])
}

Run it as a gate — a non-zero exit code fails the pipeline:

conftest test tfplan.json --policy policy/

If you are on Terraform Cloud/Enterprise, Sentinel does the same job with tfplan/v2 imports and is enforced by the platform rather than a CLI step. Pick one per organization; running both is rarely worth the maintenance. (Both approaches get a lesson of their own: OPA/Conftest plan policy gates and Sentinel policy sets and mocks.)

Wiring the suite into CI

Run the layers in order of cost. Fail fast on the cheap ones so you never spend cloud minutes on a change that a linter could have rejected. Here is a GitHub Actions workflow that stages validate -> native test -> policy -> integration.

# .github/workflows/terraform-test.yml
name: terraform-test

on:
  pull_request:

jobs:
  validate-and-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false
      - run: terraform validate
      - run: terraform test   # native unit/plan tests with mocks

  policy:
    needs: validate-and-unit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: |
          terraform init -backend=false
          terraform plan -out=tfplan.binary
          terraform show -json tfplan.binary > tfplan.json
      - uses: open-policy-agent/setup-conftest@v2
      - run: conftest test tfplan.json --policy policy/

  integration:
    needs: policy
    runs-on: ubuntu-latest
    permissions:
      id-token: write     # OIDC: short-lived cloud credentials, no static secrets
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"
      - run: cd test && go test -v -timeout 45m -parallel 4 ./...

Three things worth calling out:

Going deeper

The sections above got a suite running. This one is for when you own the suite — the semantics that decide whether a test is fast and trustworthy or slow and flaky.

How a run block actually executes

A .tftest.hcl file is a small program, and the order of its run blocks matters. Terraform executes them top to bottom, sharing state within the file: each command = apply run builds on the state left by the previous run, so you can stage a scenario — create a network, then create something that depends on it — across several runs. A later run reads an earlier run’s outputs with run.<name>.<output>.

File-level variables {} set defaults for every run; a variables {} block inside a run overrides them just for that run. A providers {} block passes specific (often aliased or mock) provider configurations into the run. And a module {} block points a run at a different module — the idiom for a setup/helper module that builds fixtures (a resource group, a random name) your real module needs as input.

# tests/integration.tftest.hcl

# Build throwaway prerequisites with a helper module first.
run "setup" {
  module {
    source = "./tests/setup"   # emits a unique suffix + resource group name
  }
}

run "create_network" {
  command = apply   # real apply unless a mock_provider is declared

  variables {
    # consume the previous run's output
    name_suffix = run.setup.name_suffix
  }

  assert {
    condition     = output.subnet_count == 3
    error_message = "expected three subnets"
  }
}

The critical thing to internalize: a non-mocked command = apply run creates real infrastructure and costs real money. terraform test cleans up after itself — at the end of the file it destroys everything it created, in reverse order — but while the file runs, that infra exists. So command = apply without a mock_provider is really a lightweight integration test living in HCL, not a unit test. Keep it deliberate.

Unit (plan) vs integration (apply): the real boundary

“Unit” and “integration” are about what touches a cloud, not about which tool you used. This table draws the line:

Dimension Native plan / mocked Native real apply Terratest
Cloud calls none yes (creates + destroys) yes (creates + destroys)
Credentials none real (sandbox) real (sandbox)
Speed sub-second minutes minutes
Cost free real real
Sees computed attributes only if overridden yes yes
Can call cloud SDKs / HTTP no no yes (Go)
Best for module logic, wiring, validation small end-to-end HCL scenarios behaviour, multi-step orchestration, non-Terraform assertions

Read it as a decision tree. Can you assert it on known or overridable values? Keep it in a mocked plan test — free and instant. Do you need a computed attribute but not a running system? A mocked apply gives you apply-time outputs without the cloud. Do you need to prove the thing works — an endpoint serves 200, a policy denies an action, a blob is readable — or to script SDK calls and retries? That is Terratest’s job, and only Terratest’s.

check blocks and pre/postconditions

Assertions do not have to live in a test file. Terraform has two in-config mechanisms that your tests also exercise:

resource "azurerm_linux_web_app" "this" {
  # ...
  lifecycle {
    postcondition {
      condition     = self.https_only
      error_message = "web app must enforce HTTPS-only"
    }
  }
}

check "app_is_healthy" {
  data "http" "health" {
    url = "https://${azurerm_linux_web_app.this.default_hostname}/healthz"
  }
  assert {
    condition     = data.http.health.status_code == 200
    error_message = "health endpoint did not return 200"
  }
}

The mental model: precondition/postcondition are guards that block a bad apply; check blocks are smoke detectors that warn continuously; run/assert in a .tftest.hcl are tests you run on demand in CI. All three share the same expression language, so an invariant you can express once can be enforced in whichever of the three fits.

Terratest internals worth knowing

Three fields on terraform.Options separate a flaky suite from a solid one. Cloud APIs throw transient errors (eventual consistency, throttling), and the fix is not to sleep — it is to retry the known transient ones and fail fast on the rest. Terratest ships a curated list:

// Auto-retry well-known transient Terraform/cloud errors.
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
	TerraformDir: "../examples/hub-spoke",
	Vars:         map[string]interface{}{"name_suffix": uniqueID},
	MaxRetries:         3,
	TimeBetweenRetries: 10 * time.Second,
	// RetryableTerraformErrors: map[string]string{ ... } // add your own
})

For fixtures that are slow to build but fast to re-check, split the test into stages so you can iterate on the assertions without re-applying every time. test_structure.RunTestStage keys each stage on a SKIP_<stage> environment variable:

import "github.com/gruntwork-io/terratest/modules/test-structure"

defer test_structure.RunTestStage(t, "teardown", func() {
	opts := test_structure.LoadTerraformOptions(t, dir)
	terraform.Destroy(t, opts)
})

test_structure.RunTestStage(t, "deploy", func() {
	opts := &terraform.Options{TerraformDir: dir}
	test_structure.SaveTerraformOptions(t, dir, opts)
	terraform.InitAndApply(t, opts)
})

test_structure.RunTestStage(t, "validate", func() {
	opts := test_structure.LoadTerraformOptions(t, dir)
	// assert on outputs; re-run with SKIP_deploy=true SKIP_teardown=true
})

Beyond Output/OutputList, terraform.OutputMap and OutputAll pull structured outputs, and terraform.InitAndPlanAndShowWithStruct gives you the parsed plan in Go when you want to assert on the plan itself rather than the applied result.

Isolation and cost in CI: ephemeral everything

Parallel integration tests are only safe if two runs can never see each other’s state or names. Two levers make that true:

  1. Isolate the state. Give every run a unique backend key so nothing shares a state file. This is the workspace-per-PR / key-per-run pattern:

    opts.BackendConfig = map[string]interface{}{
        "key": fmt.Sprintf("terratest/%s/terraform.tfstate", uniqueID),
    }
    

    On HCP Terraform the same idea is an ephemeral workspace created for the run and discarded after — you get remote state, run history, and isolation without managing keys yourself.

  2. Randomize the names. random.UniqueId() into a name_suffix (shown earlier) so two concurrent runs cannot collide on a globally-unique name like a storage account.

Even disciplined defer Destroy leaks eventually — a runner is killed, a destroy hits a dependency error. Treat cleanup as a system: tag every test resource with an owner and a timestamp, and run a scheduled sweeper that deletes anything past a TTL. That is the unchecked box on the checklist below, and it is a few lines of cron:

# .github/workflows/sweep-orphans.yml
name: sweep-orphans

on:
  schedule:
    - cron: "0 3 * * *"   # 03:00 UTC nightly
  workflow_dispatch:

jobs:
  sweep:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Delete test resources past their TTL
        run: ./scripts/sweep.sh --tag managed-by=terratest --ttl-hours 6

For very expensive fixtures, the escape hatch is to share a long-lived base environment across tests and only create the cheap, fast-changing pieces per run — cheaper and faster, but with weaker isolation, so weigh it deliberately.

Where testing stops and policy (and contracts) begin

Three overlapping practices, three different questions — keep them straight and you will not duplicate effort:

Practice Question it answers Runs against Fails when
Tests (native / Terratest) Does it work? plan values / live resources behaviour is wrong
Policy (OPA / Sentinel) Is it allowed? plan JSON resource_changes intent violates a guardrail
Contract tests Is the interface stable? a module’s inputs/outputs a change breaks consumers

Policy is not a test and should not try to be: it asserts on intent (“this SKU is too big”, “this is public”) from the plan JSON, and it belongs to the platform team as a versioned, separately-tested bundle rather than a folder of loose Rego. Contract tests are the piece most teams skip: for a published module, assert that its input variables and output names/types have not changed underneath consumers — a renamed or retyped variable is a breaking change that should fail your pipeline, not surface as a broken plan in three downstream repos next week. Promote both to first-class, versioned artifacts once your module count grows.

Verify

Confirm each layer actually runs and fails when it should:

# Native tests pass on good input
terraform test

# A deliberately broken assertion should make `terraform test` exit non-zero
terraform test || echo "native tests failed (expected for the broken case)"

# Policy catches a violation: temporarily set public access true, then:
conftest test tfplan.json --policy policy/   # expect a deny + non-zero exit

# Integration test stands up and tears down cleanly
cd test && go test -v -timeout 45m -run TestHubSpokeNetwork

After an integration run, the cloud account should contain zero leftover resources. List by your test tag or naming prefix and confirm the set is empty — that is the real proof your defer Destroy worked.

Checklist

Practice challenges

Work these in order — each builds on the last, from a plan-only assertion up to a parallel-safe integration suite. Try before opening the solution.

<details> <summary><strong>1. (Beginner) Assert a derived name in a plan-only test</strong></summary>

Given a module that outputs resource_group_name as "rg-${var.project}-${var.environment}", write a .tftest.hcl with file-level variables and one run (command plan) that asserts the output equals "rg-kloudvin-dev".

# tests/naming.tftest.hcl
variables {
  project     = "kloudvin"
  environment = "dev"
}

run "derives_rg_name" {
  command = plan
  assert {
    condition     = output.resource_group_name == "rg-kloudvin-dev"
    error_message = "resource group name not derived correctly"
  }
}

Why: command = plan never touches a cloud, so a naming/wiring bug is caught in seconds for free — the cheapest layer of the pyramid. </details>

<details> <summary><strong>2. (Beginner→Intermediate) Prove a validation rejects bad input</strong></summary>

Your environment variable has a validation block allowing only dev|test|prod. Add a run that feeds environment = "staging" and proves the validation fails.

run "rejects_unknown_environment" {
  command = plan
  variables {
    environment = "staging"
  }
  expect_failures = [
    var.environment,
  ]
}

Why: expect_failures turns “bad input should be rejected” into an assertion — otherwise a loosened validation block silently stops protecting you and no test notices. </details>

<details> <summary><strong>3. (Intermediate) Unit-test module logic with a mocked provider</strong></summary>

Without any credentials, assert that a module plans exactly var.subnet_count subnets, and that a vnet_id output matches an overridden value.

# tests/subnets.tftest.hcl
mock_provider "azurerm" {}

variables { subnet_count = 4 }

run "counts_subnets" {
  command = plan
  assert {
    condition     = length(azurerm_subnet.this) == 4
    error_message = "expected four subnets"
  }
}

run "vnet_id_is_stable" {
  command = apply           # mocked, so no cloud call
  override_resource {
    target = azurerm_virtual_network.this
    values = { id = "/subscriptions/0000/.../vnet" }
  }
  assert {
    condition     = output.vnet_id == "/subscriptions/0000/.../vnet"
    error_message = "vnet_id did not match override"
  }
}

Why: the mock keeps it sub-second and credential-free, while override_resource makes the computed id deterministic so the assertion is stable instead of matching random mock data. </details>

<details> <summary><strong>4. (Intermediate→Advanced) Gate the plan on a tagging policy</strong></summary>

Write the two commands that produce plan JSON, then a Conftest/Rego rule that denies any azurerm_resource_group missing a cost_center tag.

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# policy/tags.rego
package main

deny[msg] {
	rc := input.resource_changes[_]
	rc.type == "azurerm_resource_group"
	not rc.change.after.tags.cost_center
	msg := sprintf("%s is missing a cost_center tag", [rc.address])
}

Why: policy asserts intent on the stable resource_changes surface and blocks the change before apply — a compliance rule tests can’t express, enforced without spending a cloud minute. </details>

<details> <summary><strong>5. (Advanced) Integration test that provisions, probes, and cleans up</strong></summary>

Write a Terratest test that applies an example, asserts the app URL serves HTTP 200 with retries, and always destroys — with a randomized name suffix.

func TestWebApp(t *testing.T) {
	t.Parallel()
	suffix := random.UniqueId()

	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../examples/webapp",
		Vars:         map[string]interface{}{"name_suffix": suffix},
	})

	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)

	url := terraform.Output(t, opts, "app_url")
	http_helper.HttpGetWithRetry(t, url, nil, 200, "OK", 30, 10*time.Second)
}

Why: defer Destroy runs even when the assertion fails (no leak), the randomized suffix keeps parallel runs from colliding, and HttpGetWithRetry proves the app behaves rather than just that Terraform reported success. </details>

<details> <summary><strong>6. (Advanced) Make the suite parallel-safe and self-cleaning</strong></summary>

Two levers plus a safety net: isolate state per run, randomize names, and add a TTL sweeper for the leaks that still happen.

// unique backend key per run → no shared state
opts.BackendConfig = map[string]interface{}{
	"key": fmt.Sprintf("terratest/%s/terraform.tfstate", suffix),
}
// tag every resource so a sweeper can find orphans
opts.Vars["tags"] = map[string]string{
	"managed-by": "terratest",
	"run-id":     suffix,
}

Then schedule sweep-orphans.yml (nightly cron) to delete anything tagged managed-by=terratest older than the TTL.

Why: t.Parallel() is only safe when state and names can’t collide; the sweeper closes the gap that defer Destroy can’t — a killed runner or a destroy that errored mid-way still leaves the account clean by morning. </details>

Common beginner mistakes

Glossary

Pitfalls and next steps

The failure mode that erodes trust fastest is flakiness. Most of it traces to one of three causes: a test that sleeps instead of polling, parallel runs sharing state or names, and the Go test timeout killing an apply partway through (which both fails the test and orphans resources). Bound every wait with a retry, isolate state per run, and set the timeout above your slowest realistic apply-plus-destroy.

Even with disciplined defer Destroy, resources leak — a runner gets killed, a destroy hits a dependency error, a developer Ctrl-C’s a local run. Treat cleanup as a system, not a hope: tag every test resource (managed-by=terratest, a run ID, a timestamp) and run a scheduled sweeper that deletes anything matching the tag past a TTL. For very expensive fixtures, consider sharing a long-lived base environment across tests and only creating the cheap, fast-changing pieces per run — at the cost of weaker isolation, so weigh it carefully.

From here, the high-value extensions are contract tests for published modules (so a breaking change to an input variable fails before consumers find out), and promoting your policy bundle to a versioned, separately tested artifact rather than a folder of loose Rego files.

TerraformTestingTerratestOPACI
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments