Infrastructure as Code Testing: Terraform and OpenTofu Native Tests, Terratest, Policy Gates and Plan Review
The most expensive Terraform bug is rarely a syntax error. It is the plan that looked fine in a pull request, applied cleanly, and quietly replaced a database because someone renamed an argument inside a shared module. Nothing failed, so nothing was tested, and the first alarm was a customer. Infrastructure as code testing exists to move that discovery from production to a pull request, and the tooling to do it properly is now part of the core binaries.
This matters in 2026 because the old excuse, that you need a real cloud account and twenty minutes to test a module, no longer holds. Both Terraform and OpenTofu ship a native test framework with provider mocking. Policy engines read the machine-readable plan. Terratest still covers the cases where only a live system tells the truth. The hard part has shifted from “is it possible” to “which layer should catch which bug, at what cost”.
You will leave with a five-layer testing pyramid, runnable .tftest.hcl files with mocks and overrides, a Terratest example, policy gates on plan JSON with Checkov and Conftest, a CI layout with ephemeral accounts and cost controls, and a list of anti-patterns to avoid.
What this covers: the testing pyramid for IaC, native terraform test and tofu test, mocking limits, Terratest, policy as code on plan output, human plan review, CI design, cost control, and what goes wrong.
Context and Background
Infrastructure code has a property that application code mostly lacks: its execution has side effects on billable, shared, slow external systems, and its “unit” is hard to isolate. A function that adds two numbers can be tested in microseconds. A module that creates a VPC, subnets and route tables depends on a provider schema, a remote API and an account with quotas. For years the community answer was a thin layer of linting plus a Go library, Terratest, that applied real infrastructure and checked it from the outside.
Two developments changed the economics. First, HashiCorp added a native test framework to Terraform in v1.6.0, with provider mocking following in v1.7.0, according to the official documentation. OpenTofu implemented the same file format and mocking blocks, and its documentation for tofu test additionally accepts .tofutest.hcl files so a module can carry engine-specific tests. Second, policy tooling matured around the JSON representation of a plan, so rules can be evaluated against what will change rather than only against source text.
The landscape is therefore layered rather than competitive. Native tests are best for module contracts and logic. Terratest is best for behaviour that needs a running system and general-purpose code. Static analyzers and policy engines are best for fleet-wide rules. If you are still choosing an engine, our comparison of Terraform 1.16 and OpenTofu 1.13 divergence covers where the two have drifted apart, and the OpenTofu production migration tutorial covers the move itself.
A word on versions. When I checked the Terraform releases page on 10 November 2026, v1.17.1 (28 October 2026) was marked latest, with v1.16.7 and v1.17.0 shortly before it, and the OpenTofu test documentation I read was the v1.13 documentation. The test framework features in this post are documented for v1.6 and v1.7 onward, so they apply across those lines, but re-check the docs for your pinned version because test behaviour has been refined release by release. The authoritative references are HashiCorp’s Terraform test documentation and the OpenTofu tofu test reference.
The thesis of this post is simple. Treat each test layer as a filter with a cost and a blind spot, put the cheapest filters first, and let the expensive ones run only on changes that survive the cheap ones. Most teams do the opposite: they run one slow integration test against a real account on every commit, then wonder why nobody trusts or maintains it.
The Testing Pyramid for Infrastructure as Code
Infrastructure as code testing works best as five layers: static checks, plan-level tests, policy gates on the plan, integration tests that apply real resources in a sandbox, and end-to-end tests of a full stack. Each layer is slower and costlier than the one before it, so you run the cheap ones on every commit and the costly ones selectively.

Figure 1: The five layers of infrastructure as code testing, ordered by cost and feedback speed.
The figure shows the ordering. Static checks run in seconds and cost nothing. Plan-level tests, whether mocked or real, evaluate your logic without creating resources. Policy gates inspect the plan output. Integration tests apply modules into a throwaway account. End-to-end tests exercise a whole environment the way a user would. The cost column is qualitative on purpose: your account sizes, regions and resource types decide the dollars, and I will not invent numbers for them.
Layer 1: Static checks catch what needs no execution
terraform fmt -check, terraform validate and a linter such as TFLint catch malformed syntax, wrong types, unknown arguments and some provider-specific mistakes. They need terraform init for provider schemas but no credentials and no API calls. Their blind spot is large: they cannot see values computed at plan time, count and for_each expansion with real inputs, or anything about the remote system.
Run these first, fail fast, and keep them in a pre-commit hook as well as CI. They are cheap enough that skipping them is never worth the debate.
Layer 2: Plan-level tests verify logic without creating anything
A plan-level test runs terraform plan against a configuration with known inputs and asserts on the result. In the native framework this is a run block with command = plan. The official documentation describes command as defaulting to apply, which creates real infrastructure, and presents plan as the option that skips creation and suits unit-style checks.
Plan-level tests are where module logic belongs: does a variable validation reject a bad CIDR, does the module create one NAT gateway per availability zone when single_nat = false, does a for_each key stay stable when the input list is reordered. These are properties of your code, not of the cloud, and they should be verified in seconds.
The blind spot is that a plan cannot see values only known after apply, such as generated IDs and ARNs. Mocking addresses part of that, as covered in the next section, but remember that a plan test never proves that the provider will accept your request. A security group rule can plan perfectly and be rejected by the API for overlapping a rule you did not know about.
Layer 3: Policy gates evaluate the plan as data
Terraform can render a plan as JSON with terraform show -json, and policy engines read that document. Checkov, Trivy’s misconfiguration scanner and Open Policy Agent via Conftest all accept it. Unlike source-level scanning, plan scanning sees resolved variables, module expansion and the actual change actions (create, update, delete, replace). That last point is what lets you write rules such as “no resource of type aws_db_instance may be replaced in production without an explicit label”, which no static scanner can express.
The trade-off is that plan JSON can contain sensitive values, so the scan belongs in a protected CI job, a caution Checkov’s own documentation repeats. We return to this in detail below.
Layer 4 and 5: Integration and end-to-end tests need real infrastructure
Integration tests apply a single module into a sandbox account and verify observable behaviour: the bucket exists, the endpoint answers, the IAM role can do exactly what it should. End-to-end tests compose several modules into an environment and run smoke tests. Both need credentials, time and a teardown guarantee. Both catch the failures nothing else can: quota limits, eventual consistency, provider bugs, IAM propagation delays and real API validation.
The failure of most IaC test strategies is to jump straight to these layers because they feel “real”. They are real, and they are also slow, flaky in ways unrelated to your change, and expensive enough that teams begin to skip them. Use them sparingly and deliberately, which is the focus of the rest of this post.
What each layer can and cannot see
A useful discipline is to write down, per layer, the bug class it owns. Static checks own syntax and schema errors. Plan tests own module logic and input validation. Policy gates own organisational rules and dangerous change actions. Integration tests own provider and API behaviour. End-to-end tests own cross-module wiring. If a bug escapes, ask which layer should have caught it and move the test there, rather than adding another slow test at the top. This is the same reasoning behind the layered approach we describe in our post on Terraform state management and drift detection at scale, where the plan is the central artifact of the pipeline.
Native Tests: terraform test and tofu test in Practice
Native tests live in files ending .tftest.hcl (or .tftest.json), sit in the module directory or a tests directory, and run with terraform test or tofu test. Each file holds run blocks executed in order. A run block evaluates assertions against a plan or an applied state. Because the format is shared, the same file runs under both engines.
A module worth testing
Consider a small storage module. It validates the bucket name, optionally enables versioning, and exposes the ARN. This is deliberately simple so the tests are the focus.
# main.tf
variable "name" {
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{2,62}$", var.name))
error_message = "name must be 3-63 chars, lowercase letters, digits, hyphens."
}
}
variable "versioning" {
type = bool
default = true
}
resource "aws_s3_bucket" "this" {
bucket = var.name
}
resource "aws_s3_bucket_versioning" "this" {
count = var.versioning ? 1 : 0
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
output "arn" {
value = aws_s3_bucket.this.arn
}
Plan-level tests with a mocked provider
The test below needs no credentials and no network. The mock_provider block replaces the real AWS provider with a fake that has the same schema.
# tests/bucket.tftest.hcl
mock_provider "aws" {
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mock-bucket"
}
}
}
variables {
name = "team-logs-prod"
}
run "versioning_on_by_default" {
command = plan
assert {
condition = length(aws_s3_bucket_versioning.this) == 1
error_message = "versioning should be enabled by default"
}
}
run "versioning_can_be_disabled" {
command = plan
variables {
versioning = false
}
assert {
condition = length(aws_s3_bucket_versioning.this) == 0
error_message = "no versioning resource expected when disabled"
}
}
run "rejects_bad_name" {
command = plan
variables {
name = "Bad_Name"
}
expect_failures = [var.name]
}
run "apply_with_mock_exposes_arn" {
command = apply
assert {
condition = output.arn == "arn:aws:s3:::mock-bucket"
error_message = "mock default arn not propagated"
}
}
Run it with terraform test or tofu test. The last run uses command = apply, but because the provider is mocked, nothing is created anywhere; the “apply” operates on the fake. That is the point of mocking: you exercise the apply-time graph, including values that are only known after apply, without touching a cloud.
Three details from the documentation matter here. First, expect_failures applies only to custom conditions such as variable validations, preconditions, postconditions and check blocks, not to errors raised by a provider, and the Terraform docs warn that using it with apply is risky because a plan failure also fails the run. Second, test file variables take the highest precedence, and run-level values override file-level ones. Third, at the end of a test file the framework tries to destroy whatever the runs created, in reverse run order, and reports resources it could not destroy.
Mocks, overrides and what they really prove

Figure 2: How a test run resolves provider calls, either to a real cloud API or to a mock with generated values and overrides.
A mock provider has the same schema as the real one but never calls an API. Per the Terraform mocking documentation, it keeps the values you configure and generates values for computed attributes: numbers become 0, booleans false, strings a random eight-character alphanumeric string, collections empty, and objects are filled recursively. mock_resource and mock_data let you set better defaults per type. override_resource, override_data and override_module pin values for a specific address, or replace a module’s outputs entirely.
Here is a data-source override, useful when a module reads an existing VPC:
run "uses_existing_vpc" {
command = plan
override_data {
target = data.aws_vpc.selected
values = {
id = "vpc-0123456789abcdef0"
cidr_block = "10.20.0.0/16"
}
}
assert {
condition = aws_security_group.app.vpc_id == "vpc-0123456789abcdef0"
error_message = "security group must attach to the selected VPC"
}
}
The documented limitations define the boundary of what mocks prove. Mocks generate only computed attributes, so you must still supply required ones. The generated strings rarely match the format a real provider returns, so a mocked ARN will not satisfy a downstream regex that expects a real ARN shape. Repeated blocks cannot be targeted instance by instance. Mock and real providers share a namespace, so using both in one file requires an alias. And mocking works only inside the test framework.
The conclusion is that a green mocked test proves your configuration logic and your wiring between resources, not that the cloud will accept the request. That is precisely why it is cheap, and why it must not be your only layer.
OpenTofu specifics and portable test suites
OpenTofu’s tofu test reads *.tftest.hcl, *.tftest.json, *.tofutest.hcl and *.tofutest.json files from the current directory or a tests directory, with -test-directory to change the location. If a .tofutest file and a .tftest file share a base name in the same directory, only the .tofutest file loads. That is a deliberate escape hatch: a module that supports both engines can keep a shared suite in .tftest.hcl and override one file for OpenTofu-only behaviour.
The OpenTofu documentation also records differences worth knowing. From OpenTofu 1.12, override_resource and override_data can target individual instances of for_each or count resources and accept a [*] wildcard for all instances, which Terraform’s mocking page lists as unsupported (“you can’t target individual instances”). The OpenTofu page also notes that override_module turns unspecified outputs into null, requires every instance of the module call to be overridden, and that cross-run references such as run.setup.filename do not work with command = plan runs (tracked as issue 3754). Treat these as version-specific and re-read the docs for your pinned release before depending on them.
For a portable module, the practical rule is to keep assertions on plain configuration behaviour, avoid engine-specific blocks in shared files, and run the same suite under both binaries in CI with a matrix. If both pass, you have verified the contract your consumers actually rely on, not one engine’s quirks.
Parallelism, state keys and command-line flags
Runs execute sequentially by default. The Terraform documentation says they can run in parallel only when they do not reference each other’s outputs, use different state files, and both set parallel = true; state_key selects the state file a run uses. For plan-only suites with many independent cases this can cut wall-clock time, but it adds a subtle failure class where two runs silently share state because nobody set a key. Start sequential and parallelise only when a suite’s runtime justifies it.
Useful terraform test flags documented by HashiCorp include -filter to run specific files, -test-directory, -verbose to print each run’s plan or state, -json for machine-readable output, -junit-xml to write a JUnit report that CI systems can render, -parallelism (default 10) and -run-parallelism (default 10). The JUnit flag is documented as incompatible with -cloud-run, which executes a test remotely in HCP Terraform against a private registry module. OpenTofu documents -filter, -test-directory, -var, -var-file, -json, -json-into, -no-color and -verbose; I did not find a documented JUnit flag in the OpenTofu page, so confirm before relying on one.
Module contract testing
A module’s contract is its inputs, outputs, and the guarantees it makes: defaults, validations, tags applied, encryption on, no public access. Contract tests assert exactly those and nothing about internals, so refactoring does not break them. Good contract tests include one run per validation rule, one per boolean feature flag toggled both ways, one that asserts mandatory tags reach every taggable resource, and one that asserts outputs keep their names and types.
Resist testing resource internals such as the exact count of sub-resources in a way that mirrors the implementation. If a test fails every time you refactor safely, it tests the implementation and will be deleted by the first frustrated maintainer.
Terratest: When You Need Real Infrastructure and Real Code
Terratest is a Go library from Gruntwork that applies Terraform (or OpenTofu, via configuration) in a test, reads outputs, makes real requests against what was built, and destroys it. Where native tests assert on configuration state, Terratest can do arbitrary things: open an SSH session, call an HTTP endpoint with retries, query a database, or invoke a cloud SDK to verify an IAM policy is effective.
package test
import (
"fmt"
"testing"
"time"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestWebModule(t *testing.T) {
t.Parallel()
name := fmt.Sprintf("tt-%s", random.UniqueId())
opts := &terraform.Options{
TerraformDir: "../examples/web",
TerraformBinary: "tofu", // or "terraform"
Vars: map[string]interface{}{"name": name},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
url := terraform.Output(t, opts, "url")
http_helper.HttpGetWithRetry(t, url, nil, 200, "ok", 30, 10*time.Second)
}
Note the defer terraform.Destroy placed before the apply, the unique name suffix so parallel runs cannot collide, and the retry helper because newly created infrastructure is rarely ready immediately. I have not verified the current Terratest release version or every field name against the latest source in this run, so check pkg.go.dev for the version you pin; the pattern above (Options, InitAndApply, Output, Destroy, HTTP retry helper) is the long-standing core of the library.
Native tests versus Terratest
| Concern | Native test (.tftest.hcl) |
Terratest (Go) |
|---|---|---|
| Language | HCL, same as the code | Go, general purpose |
| Mocked providers | Yes, built in | No, you use real infrastructure |
| Plan-only checks | command = plan |
Possible, awkward |
| Call a live endpoint | Not directly | First class, with retries |
| Arbitrary SDK checks | No | Yes |
| Learning curve for infra team | Low | Needs Go skills |
| Parallel isolation | state_key, parallel runs |
t.Parallel() plus unique names |
| Best for | Module logic, validation, wiring | Behaviour of a running system |
The decision rule I use: write the assertion natively if it is about configuration or planned state; reach for Terratest only if the claim cannot be made without running something against the deployed resource. A team that writes everything in Terratest ends up with slow suites and a Go skills bottleneck. A team that writes everything natively ends up never validating that anything works.
Policy Gates and Plan Review
Policy as code tests different things from unit tests. A unit test asks “does this module behave as designed”. A policy asks “does this change comply with rules the organisation set, whoever wrote it”. Because policies apply to every repository, they must run on a stable, machine-readable artifact. For IaC policy testing that artifact is the plan JSON.

Figure 3: Generating plan JSON once and fanning it out to several policy engines, with a human review after the automated gate passes.
Producing the plan artifact
The documented sequence, from Checkov’s plan-scanning page, is to create a binary plan, render it as JSON, and scan the JSON:
terraform init
terraform plan -out tfplan.binary
terraform show -json tfplan.binary > tfplan.json
checkov -f tfplan.json
checkov -f tfplan.json --repo-root-for-plan-enrichment .
The enrichment flag makes Checkov take code blocks and resource identifiers from your .tf files and honour skip comments in them, which makes findings far easier to act on. Checkov’s documentation also states that a few checks (CKV_AWS_217, CKV_AWS_233, CKV_AWS_237 and CKV_GCP_82) are skipped for plan files because they evaluate lifecycle blocks that the plan does not store. That is a concrete example of a blind spot: plan scanning and source scanning find different things, so run both on important repositories.
OpenTofu users produce the same document with tofu plan -out and tofu show -json. Trivy’s configuration scanner is also documented to accept Terraform plan JSON for misconfiguration checks; I did not re-verify the exact flags in this run, so confirm against the Trivy docs for your version. For how Trivy fits a wider supply-chain pipeline, see our Trivy versus Grype and Cosign pipeline post.
Custom rules with Conftest and Rego
Built-in checks cover common cloud misconfigurations. Organisation-specific rules need a policy language. Conftest runs Open Policy Agent Rego policies against structured files with conftest test plan.json --policy policy. Its documentation describes the main namespace default, deny, violation and warn rule kinds, and --namespace or --all-namespaces for others. The Conftest page does not describe the Terraform plan schema, so the field paths below come from the plan JSON format itself (resource_changes, each with address, type and change.actions); verify them against a plan from your own version.
package main
import rego.v1
# Block replacement or deletion of databases unless explicitly allowed.
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_db_instance"
"delete" in rc.change.actions
msg := sprintf("%s would be destroyed or replaced", [rc.address])
}
# Require an owner tag on anything that supports tags.
deny contains msg if {
some rc in input.resource_changes
"create" in rc.change.actions
rc.change.after.tags != null
not rc.change.after.tags.owner
msg := sprintf("%s is missing the owner tag", [rc.address])
}
The first rule is the high-value one: a replacement appears in the plan as both delete and create actions, so checking for delete catches destroy and replace. The second shows tag enforcement, which is the classic first policy every organisation writes. Both are illustrative; test them against real plans and keep them small.
Rego policies are code and deserve tests. Conftest and OPA support unit tests for rules, using fixture plan files, so a policy change cannot silently stop blocking what it should. If you already run policy engines inside Kubernetes, our comparison of Kyverno versus OPA Gatekeeper discusses the same authoring trade-offs, and the AWS IAM policy evaluation logic guide helps you decide which rules belong in cloud-native guardrails rather than in the pipeline.
Human plan review still matters
Automated gates catch known classes of problems. A reviewer catches the unknown ones, but only if the plan is presented well. Post a compact summary to the pull request: counts of create, update, delete and replace per environment, a list of every resource being destroyed or replaced, and the policy results. Highlight “forces replacement” lines, because they are the dangerous ones.
Make the gate asymmetrical. Creates and in-place updates of low-risk resource types can pass on policy alone. Destroys and replaces of stateful resources should require a named approver. And review the plan that will actually be applied: save the binary plan as a CI artifact and apply that exact file, rather than re-planning at apply time, otherwise the reviewed plan and the applied plan can differ if the world changed in between. Our post on Terraform state, drift detection and plan-apply pipelines goes deeper on this pattern.
One caution on secrets. Plan JSON can contain sensitive values, a risk Checkov’s documentation calls out and recommends handling by running plan evaluation in a secure CI/CD pipeline. Do not upload plan JSON as a public build artifact, restrict who can download it, and avoid pasting raw plans into pull request comments.
A CI Pipeline, Ephemeral Accounts and Cost Control
The layers only help if CI runs them in the right order with the right credentials. The principle is progressive cost: each stage runs only if the cheaper stages passed, and credentials appear only in the stages that need them.

Figure 4: A pull request pipeline that escalates from free checks to sandbox integration tests, with nightly end-to-end runs and orphan cleanup on the main branch.
A GitHub Actions layout
This workflow shows the shape. It is a template to adapt, not a tested drop-in: action versions and the OIDC role setup are placeholders you must pin and configure for your organisation.
name: iac-tests
on:
pull_request:
paths: ["modules/**", "envs/**"]
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
- run: tofu fmt -check -recursive
- run: tofu init -backend=false && tofu validate
unit:
needs: static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
- run: tofu init -backend=false
- run: tofu test -json-into=test-results.json
policy:
needs: static
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
# assume a read-only role via OIDC here, then:
- run: |
tofu init
tofu plan -out tfplan.binary
tofu show -json tfplan.binary > tfplan.json
pip install checkov
checkov -f tfplan.json --repo-root-for-plan-enrichment .
conftest test tfplan.json --policy policy
integration:
needs: [unit, policy]
if: contains(github.event.pull_request.labels.*.name, 'run-integration')
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
concurrency: sandbox-${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v4
# assume a sandbox-only role via OIDC here, then:
- run: go test ./test/... -timeout 45m -parallel 4
Notice the choices. The unit job runs with no cloud credentials at all, which is only possible because the tests are mocked, a security benefit as well as a speed one. The policy job needs a read-only role because planning refreshes state. The integration job is opt-in by label, serialised per pull request, and has a hard timeout. Keeping it opt-in is a pragmatic cost control; many teams run it automatically only when files in a module directory changed.
Ephemeral accounts and blast radius
Integration tests should never run in an account that holds anything you care about. The safest pattern is a dedicated sandbox account (or a project or subscription on other clouds) per team or per run, with these properties: a deny policy on expensive or dangerous services, budget alerts, service quotas lowered to realistic limits, no network path to production, and credentials issued through short-lived federation rather than static keys.
Per-run isolation keeps parallel tests from colliding. Give every run a unique prefix (Terratest’s random.UniqueId() serves this purpose), tag every resource with the run identifier and an expiry time, and make names derive from that prefix so a cleanup job can find them. In native tests, remember that cleanup runs at the end of a test file; if the runner is killed halfway, nothing runs, so you still need a sweeper.
Cost control that does not depend on goodwill
Cost control for IaC tests is mostly structural. Prefer resources that are cheap or free to create and quick to destroy, and test expensive resource types through mocks or by planning only. Never run a full-environment end-to-end test on every pull request; run it nightly or on release branches. Set timeouts on every job so a hung teardown does not accrue charges, and run a scheduled orphan sweeper that deletes resources tagged as test resources past their expiry. I will not quote dollar figures because they depend on your provider, region and resource mix, but you can estimate your own by multiplying resource lifetime by hourly price and by runs per day, then checking the product against a monthly test budget.
Budget alarms on the sandbox account are the backstop. If an alarm fires, the right response is a script that tears down tagged resources, not an email to a human who might read it on Monday.
Test data, secrets and flakiness
Tests that apply real infrastructure fail for reasons unrelated to your change: eventual consistency, quota exhaustion, a provider API having a bad hour. Handle this with bounded retries on observation steps (not on the apply itself), clear separation between “test failed” and “environment failed”, and a quarantine list for known flaky tests so one bad test does not erode trust in the entire suite. Keep secrets out of test variables; use short-lived credentials and generate random passwords inside the test rather than committing them.
Trade-offs, Gotchas, and What Goes Wrong
Mocks that lie. A mock provider generates plausible values, not correct ones. A module that parses an ARN, validates an ID format, or branches on a provider-returned string can pass under a mock and fail against the real API. Set realistic defaults for any computed attribute your code inspects, and keep at least one real-provider test for each such behaviour.
Testing the provider instead of your code. An assertion that a bucket resource has the bucket name you passed tests the provider’s plumbing, not your logic. Assert on decisions your module makes: counts, conditionals, derived names, merged tags, dynamic blocks.
Apply-mode tests in the unit layer. If a test uses command = apply against a real provider, it is an integration test regardless of where the file lives. Mixing them makes the fast suite slow and puts credentials in the wrong job. Keep real-apply tests in a separate directory and select them with -test-directory or -filter.
Shared state between runs. Runs that share a state file build on each other, which is useful for staged tests but creates hidden order dependencies. When parallelising, give independent runs distinct state_key values, and treat any cross-run output reference as a coupling you chose on purpose.
Plan-only blind spots. A clean plan says nothing about provider-side validation, quotas or IAM propagation. The expect_failures mechanism covers only custom conditions you wrote, not errors raised by a provider, so it cannot be used to assert that the cloud rejects something.
Policy drift and false confidence. A policy that was written once and never tested decays. Provider upgrades change resource schemas, a renamed attribute makes a Rego rule match nothing, and a gate that silently matches nothing is worse than no gate. Add unit tests for every rule with a passing and a failing fixture, and alert when a policy evaluates zero resources over a long period.
Gate fatigue. Dozens of low-value findings train reviewers to click through. Start with a small set of blocking rules tied to real incidents, mark the rest as warnings, and promote a warning only when you have cleaned up its existing violations.
Testing in production by accident. A test pointed at the wrong backend or credentials can modify real state. Tests should never configure a remote backend of a real environment, should run under credentials that physically cannot reach production, and should fail loudly if an environment variable naming the target account does not match the expected sandbox identifier.
Orphaned resources. Cleanup is best effort. Interrupted jobs, destroy-time dependency errors and cancelled runs all leave resources behind. Treat the sweeper as part of the test system, not an optional extra.
Treating tests as documentation of intent only. Tests must fail when behaviour changes. A suite that has never been seen to fail is unproven; occasionally introduce a deliberate regression in a branch to confirm the suite catches it.
Practical Recommendations
Start from the cheapest layer and add upward only when a real escaped bug justifies the cost. For a team new to this, a sensible order is: formatting and validation in CI first, then plan-level native tests with a mocked provider for the three or four most-reused modules, then a small set of policy rules derived from past incidents, and only then sandbox integration tests for the modules whose failures hurt most.
Write contract tests, not implementation tests. Pin your engine version in CI, and run the native suite under both Terraform and OpenTofu if your modules are consumed by both. Keep plan JSON handling inside a protected job, apply the exact plan that was reviewed, and require a named approver for destroys and replaces of stateful resources. For deeper pipeline automation, our post on programmable CI/CD with Dagger shows how to make these stages portable across CI vendors, and if your repositories receive AI-written changes, the AI-generated code CI/CD guardrails post covers the extra checks they need.
Checklist to adopt this week:
- [ ]
fmt -checkandvalidaterun on every pull request, with provider init but no credentials. - [ ] Each shared module has a
tests/directory with at least one mocked, plan-level run per validation rule and feature flag. - [ ] Test results are exported as JUnit XML or JSON and rendered in CI.
- [ ] Plan JSON is generated once per change, scanned by Checkov and at least one custom Rego policy set, and stored only in a protected artifact location.
- [ ] Every Rego rule has a passing and a failing fixture test.
- [ ] Integration tests run in a dedicated sandbox account using short-lived federated credentials, with unique prefixes, expiry tags and hard timeouts.
- [ ] A scheduled sweeper deletes expired test resources, and budget alarms trigger teardown.
- [ ] Destroys and replaces of stateful resources require a named approver and apply the reviewed plan file.
Frequently Asked Questions
What is infrastructure as code testing?
Infrastructure as code testing is the practice of verifying infrastructure definitions automatically before they change real systems. It spans formatting and validation, plan-level tests of module logic, policy checks on the plan, integration tests that deploy into a sandbox, and end-to-end tests of whole environments. The goal is to catch destructive or non-compliant changes in a pull request, using the cheapest layer that can detect each class of bug.
Does terraform test create real infrastructure?
By default, yes. A run block uses command = apply, which creates real resources and destroys them when the test file finishes. Setting command = plan skips creation and is intended for unit-style checks. You can also replace a provider with mock_provider, available from Terraform v1.7.0, so even apply-mode runs touch nothing real. Check the documentation for your pinned version, because behaviour has been refined across releases.
Is tofu test the same as terraform test?
They share the .tftest.hcl format and the core blocks: run, assert, expect_failures, mocks and overrides. OpenTofu additionally loads .tofutest.hcl files, preferring them over same-named .tftest.hcl files, and its documentation describes features such as instance-level overrides from OpenTofu 1.12. Terraform adds options such as -junit-xml and -cloud-run. For portable modules, keep shared tests engine-neutral and run them under both.
Should I use Terratest or native tests?
Use native tests for configuration logic, input validation, resource wiring and anything that can be asserted on a plan or mocked apply. Use Terratest when the claim needs a running system or general-purpose code: calling an endpoint, opening a connection, or checking effective permissions through an SDK. Most teams are best served by many fast native tests and a small number of Terratest integration tests for their riskiest modules.
How do I run Checkov or OPA on a Terraform plan?
Create a binary plan with terraform plan -out tfplan.binary, convert it with terraform show -json tfplan.binary > tfplan.json, then run checkov -f tfplan.json or conftest test tfplan.json --policy policy. Adding --repo-root-for-plan-enrichment makes Checkov report findings against your source files. Run this in a protected CI job, because plan JSON can contain sensitive values, and keep the artifact private.
How do I stop IaC tests from running up a cloud bill?
Mock what you can, plan instead of apply where possible, and run real-apply tests in a dedicated sandbox account with budgets, quotas and deny policies on costly services. Tag everything with a run identifier and expiry, set hard timeouts, run expensive end-to-end tests nightly rather than per pull request, and schedule a sweeper that deletes expired test resources. Cost depends on your provider and resources, so measure your own.
Further Reading
- Terragrunt vs OpenTofu vs Terraform Stacks: monorepo and state management, for how test layout interacts with repository structure.
- PLM graph database impact analysis and where-used queries, a parallel way of thinking about change propagation and blast radius.
- Sensor calibration, drift compensation and measurement uncertainty, on trusting a measurement instrument, a useful analogy for trusting a test suite.
- Terraform state management and drift detection at scale
- Terraform vs Pulumi vs Crossplane
- Terraform test documentation (HashiCorp)
- Mocking in Terraform tests (HashiCorp)
- tofu test command reference (OpenTofu)
- Checkov Terraform plan scanning
By Riju — about
