Terraform

terraform test: Unit-Test Modules Without a Second CI Language

How to write terraform test files that catch module regressions in plan — run blocks, assert, mock providers, and when you still need Terratest.

2026-09-06 · 3 min read

terraform test (1.6+) runs HCL tests against a module. You do not need Go, a cloud account, or a 400-line Terratest file to check that “private subnets are planned when enable_private is true.”

Use it for module contracts. Use Terratest or a real staging apply for “this VPC actually pings.”

File layout

modules/network/
  main.tf
  variables.tf
  outputs.tf
  tests/
    private_subnets.tftest.hcl
    no_public_by_default.tftest.hcl

From the module directory:

terraform test
terraform test -verbose

CI should run terraform test on every module PR, before any root-level plan.

A plan-only test

# tests/private_subnets.tftest.hcl
variables {
  cidr_block             = "10.20.0.0/16"
  enable_private_subnets = true
  azs                    = ["us-east-1a", "us-east-1b"]
}

run "plans_private_subnets" {
  command = plan

  assert {
    condition     = length(aws_subnet.private) == 2
    error_message = "Expected two private subnets, got ${length(aws_subnet.private)}."
  }

  assert {
    condition     = alltrue([for s in aws_subnet.private : s.map_public_ip_on_launch == false])
    error_message = "Private subnets must not auto-assign public IPs."
  }
}

command = plan never calls Create. It still needs provider init, so pin providers in the module (or a terraform block in the test).

Apply tests and mocks

command = apply is slower and needs credentials unless you mock:

mock_provider "aws" {
  alias = "fake"
}

run "with_mock" {
  command = apply

  providers = {
    aws = aws.fake
  }

  assert {
    condition     = output.vpc_id != ""
    error_message = "vpc_id should be set after apply."
  }
}

Mocks are good for output wiring and count / for_each branches. They will not catch an invalid AWS API argument. Keep one nightly apply-to-a-scratch-account job for that.

Testing expected failures

A module should reject a /32 “VPC”:

run "rejects_tiny_cidr" {
  command = plan

  variables {
    cidr_block = "10.0.0.1/32"
  }

  expect_failures = [
    var.cidr_block,
  ]
}

expect_failures is for validation {} blocks and precondition. If you do not list the address, a failed validation fails the test — which is what you want when you did not expect it.

Root modules vs child modules

Test the reusable module, not the whole company root. Roots have remote state, 12 providers, and take 8 minutes to plan. If a root needs a test, extract the risky bit (IRSA trust policy, listener rules) into a module and test that.

What not to assert

  • Exact random IDs or arn:... strings that change every apply
  • Provider-computed values you do not control
  • “Resource count == 47” — brittle. Assert the invariant (“at least one private route table”).

CI sketch

- name: Terraform tests
  working-directory: modules/network
  run: |
    terraform init -backend=false
    terraform test

No backend. No cloud keys. Fail the PR if an assert fails.

Pair with plan-file CI for roots and for_each vs count so a green module test is not undone by a reckless root apply.

Common pitfalls

  1. Applying tests in CI against prod credentials. Use plan or mocks by default.
  2. Tests that only init. An empty run block is not a test.
  3. Asserting length(resource) == n after a feature flag without a second run that covers the flag off.
  4. Putting tests only in the root. Reviewers will skip them when the root takes 10 minutes.
  5. Terratest or terraform test. You want both layers; they catch different bugs.

When to reach for Terratest

  • You need SSH/HTTP against a real instance
  • You are testing a provisioner or a cloud-side eventual consistency
  • The invariant is “DNS resolves after 60s”

That is integration. Keep it nightly. Keep terraform test on every commit.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.