Application code that stores clinical documents goes through unit tests, a review and a pipeline before it ships. The Terraform that creates the bucket those documents live in often goes through a plan that someone skims. The usual reason is practical: testing infrastructure code has meant standing up real infrastructure, which needs an account, credentials, minutes per run and a clean-up that sometimes fails.
That reason has gone. terraform test with a mocked provider runs a module’s tests in seconds with no account at all, and the plan that Terraform already produces can be checked by a program before a human reads it. This article builds a small, realistic module and puts three checks around it in CI: unit tests against a mock, a mutation check that proves the tests would notice a defect, and a policy gate over the JSON plan. Everything runs on both Terraform 1.16 and OpenTofu 1.12, and the two places where they differ are called out.
What the tests have to establish
- The module configures what it claims. Encryption with a customer-managed key, versioning, all four public access settings, a TLS-only policy, sensible retention.
- Its input rules hold. No
force_destroyin production. No short retention for clinical data. No malformed names. - The tests are not decorative. If someone weakens the module, a test fails and says which rule broke.
- Code outside the module is covered too. A bucket written by hand in a root configuration never runs the module’s tests. Something has to look at the plan.
- No credentials in the pull-request pipeline. Tests that need a cloud account get run less, and a pipeline that holds cloud credentials for untrusted branches is a risk of its own.
- Seconds, not minutes, so the checks run on every push.

The module under test
The module creates an S3 bucket for clinical documents with the controls an information governance team would ask for. It is deliberately ordinary.
data "aws_caller_identity" "current" {
count = var.account_id == null ? 1 : 0
}
locals {
account_id = var.account_id != null ? var.account_id : one(data.aws_caller_identity.current[*].account_id)
bucket_name = "${var.name}-${var.environment}-${local.account_id}"
tags = {
environment = var.environment
data-classification = var.data_classification
managed-by = "terraform"
}
}
resource "aws_s3_bucket" "this" {
bucket = local.bucket_name
force_destroy = var.force_destroy
tags = local.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
bucket_key_enabled = true
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
id = "expire-superseded-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = var.noncurrent_retention_days
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
depends_on = [aws_s3_bucket_versioning.this]
}
resource "aws_s3_bucket_policy" "this" {
bucket = aws_s3_bucket.this.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [aws_s3_bucket.this.arn, "${aws_s3_bucket.this.arn}/*"]
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
}
]
})
depends_on = [aws_s3_bucket_public_access_block.this]
}
Most of the policy lives in the variables, as validation rules. Two of them compare one variable with another, which Terraform has allowed since 1.9:
variable "name" {
description = "Short name of the bucket. The environment and account id are appended."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}[a-z0-9]$", var.name))
error_message = "name must be 4 to 32 characters: lower-case letters, digits and hyphens, starting with a letter."
}
}
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "environment must be dev, test or prod."
}
}
variable "data_classification" {
description = "Classification of the data the bucket will hold."
type = string
validation {
condition = contains(["internal", "confidential", "clinical"], var.data_classification)
error_message = "data_classification must be internal, confidential or clinical."
}
}
variable "kms_key_arn" {
description = "ARN of the customer-managed KMS key used for default encryption."
type = string
validation {
condition = can(regex("^arn:aws[a-z-]*:kms:[a-z0-9-]+:[0-9]{12}:key/.+$", var.kms_key_arn))
error_message = "kms_key_arn must be the ARN of a KMS key."
}
}
variable "noncurrent_retention_days" {
description = "Days a superseded object version is kept before it expires."
type = number
default = 365
validation {
condition = var.noncurrent_retention_days >= 30
error_message = "noncurrent_retention_days must be at least 30."
}
validation {
condition = var.data_classification != "clinical" || var.noncurrent_retention_days >= 365
error_message = "Clinical data must keep superseded versions for at least 365 days."
}
}
variable "force_destroy" {
description = "Allow the bucket to be destroyed while it still holds objects. Never in production."
type = bool
default = false
validation {
condition = !(var.force_destroy && var.environment == "prod")
error_message = "force_destroy cannot be enabled in prod."
}
}
variable "account_id" {
description = "AWS account id. When null it is looked up from the caller's credentials."
type = string
default = null
}
One design choice is there for testability. The account id can be passed in; only when it is not does the module look it up. That gives the tests, and the policy gate later, a way to run with no call to AWS, and it is also a reasonable interface for a module used from a pipeline that already knows its account.
Unit tests against a mocked provider
A mock_provider block replaces the real provider for the duration of a test file. Terraform still downloads the provider, because it needs the schema, but never configures it and never calls AWS. Arguments the configuration sets are passed through unchanged. Attributes the provider would compute, such as ARNs and ids, are generated: a random eight-character string for a string, zero for a number, unless the test supplies a default.
# Unit tests. No AWS account, no credentials, no calls to AWS: the provider is mocked.
# The file runs unchanged under "terraform test" and "tofu test".
#
# Rule used throughout: assert on values the configuration sets in "plan" runs, and on values
# the provider computes (ARNs, ids, anything read from a data source) in "apply" runs.
# An apply against a mocked provider creates nothing and costs nothing.
mock_provider "aws" {
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mocked-bucket"
}
}
mock_data "aws_caller_identity" {
defaults = {
account_id = "444455556666"
}
}
}
variables {
name = "clinical-docs"
environment = "dev"
data_classification = "clinical"
kms_key_arn = "arn:aws:kms:eu-west-1:111122223333:key/11111111-2222-3333-4444-555555555555"
account_id = "111122223333"
}
run "name_carries_environment_and_account" {
command = plan
assert {
condition = aws_s3_bucket.this.bucket == "clinical-docs-dev-111122223333"
error_message = "The bucket name must be <name>-<environment>-<account id>."
}
assert {
condition = aws_s3_bucket.this.tags["data-classification"] == "clinical"
error_message = "The bucket must carry its data classification as a tag."
}
}
run "explicit_account_id_skips_the_lookup" {
command = plan
assert {
condition = length(data.aws_caller_identity.current) == 0
error_message = "No caller identity lookup should happen when account_id is given."
}
}
run "account_id_is_looked_up_when_not_given" {
command = apply
variables {
account_id = null
}
assert {
condition = length(data.aws_caller_identity.current) == 1
error_message = "The caller identity must be looked up when account_id is null."
}
assert {
condition = aws_s3_bucket.this.bucket == "clinical-docs-dev-444455556666"
error_message = "The looked-up account id must be used in the name."
}
}
run "default_encryption_uses_the_customer_key" {
command = plan
assert {
condition = one(aws_s3_bucket_server_side_encryption_configuration.this.rule).apply_server_side_encryption_by_default[0].sse_algorithm == "aws:kms"
error_message = "Default encryption must be SSE-KMS."
}
assert {
condition = one(aws_s3_bucket_server_side_encryption_configuration.this.rule).apply_server_side_encryption_by_default[0].kms_master_key_id == var.kms_key_arn
error_message = "Default encryption must use the key that was passed in."
}
}
run "public_access_is_blocked_four_ways" {
command = plan
assert {
condition = alltrue([
aws_s3_bucket_public_access_block.this.block_public_acls,
aws_s3_bucket_public_access_block.this.block_public_policy,
aws_s3_bucket_public_access_block.this.ignore_public_acls,
aws_s3_bucket_public_access_block.this.restrict_public_buckets,
])
error_message = "All four public access block settings must be true."
}
}
run "versioning_is_on" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Versioning must be enabled."
}
}
run "policy_denies_plain_http" {
# The policy embeds the bucket ARN, which the provider computes: an apply run.
command = apply
assert {
condition = jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Effect == "Deny"
error_message = "The transport statement must be a Deny."
}
assert {
condition = jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Condition.Bool["aws:SecureTransport"] == "false"
error_message = "The policy must deny requests that are not made over TLS."
}
assert {
condition = contains(jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Resource, "arn:aws:s3:::mocked-bucket/*")
error_message = "The policy must cover the objects in the bucket."
}
}
run "superseded_versions_are_kept_for_a_year_by_default" {
command = plan
assert {
condition = aws_s3_bucket_lifecycle_configuration.this.rule[0].noncurrent_version_expiration[0].noncurrent_days == 365
error_message = "The default retention for superseded versions must be 365 days."
}
}
run "rejects_a_malformed_name" {
command = plan
variables {
name = "Clinical_Docs"
}
expect_failures = [var.name]
}
run "rejects_force_destroy_in_prod" {
command = plan
variables {
environment = "prod"
force_destroy = true
}
expect_failures = [var.force_destroy]
}
run "rejects_short_retention_for_clinical_data" {
command = plan
variables {
noncurrent_retention_days = 90
}
expect_failures = [var.noncurrent_retention_days]
}
run "allows_short_retention_for_internal_data" {
command = plan
variables {
data_classification = "internal"
noncurrent_retention_days = 90
}
assert {
condition = aws_s3_bucket_lifecycle_configuration.this.rule[0].noncurrent_version_expiration[0].noncurrent_days == 90
error_message = "Internal data may use a shorter retention."
}
}
run "rejects_a_key_that_is_not_a_kms_arn" {
command = plan
variables {
kms_key_arn = "alias/my-key"
}
expect_failures = [var.kms_key_arn]
}
run "apply_with_mocks_exposes_outputs" {
command = apply
assert {
condition = output.bucket_arn == "arn:aws:s3:::mocked-bucket"
error_message = "bucket_arn must be the ARN of the bucket resource."
}
assert {
condition = output.bucket_name == "clinical-docs-dev-111122223333"
error_message = "bucket_name must be the name of the bucket resource."
}
}
Four things about that file are worth copying.
- Plan runs for what you configure, apply runs for what the provider computes. The encryption algorithm, the public access flags and the versioning status are values the module sets, so a
planrun can assert on them. The bucket policy embeds the bucket ARN, and the bucket name can depend on a data source; those are only known after apply. An apply against a mock creates nothing, so there is no reason to avoid it. - Defaults only where a test reads the value. The mock gives the bucket a fixed ARN because one assertion checks that the policy contains it. Everything else is left to generation.
- Negative tests with
expect_failures. A run that names a variable inexpect_failurespasses only if that variable’s validation fails. Four of the fourteen runs are of this kind. They are the tests for the rules a reviewer cares about most. - One valid baseline, small overrides. The file-level
variablesblock is a valid configuration. Each run changes one thing, so a failure points at one rule.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
A[Push or pull request] --> B[init: download provider schema, no credentials]
B --> C[terraform test and tofu test: 14 unit tests on a mocked provider]
C --> D[Mutation check: ten defects, each must fail a named test]
D --> E[plan of each root module, show -json]
E --> F[Policy gate over the JSON plan]
F --> G{Any failure?}
G -->|Yes| H[Build fails with the rule and the resource]
G -->|No| I[Merge allowed. Real plan and apply happen later, with credentials]
classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
class A,B,C,D,E,G compute
class F policy
class H danger
class I storeOn Terraform 1.16.3 the file reports Success! 14 passed, 0 failed. On OpenTofu 1.12.6 the same file, unchanged, reports the same.
Pin the provider the tests run against
A module declares a range of provider versions it accepts. Its tests should run against one known version, or a provider release can change a test result on a day nobody touched the code. The dependency lock file does that, and it does not have to be generated on a workstation first:
# Pins the provider the tests run against. Hashes are added by "terraform init".
provider "registry.terraform.io/hashicorp/aws" {
version = "6.63.0"
constraints = "~> 6.0"
}
Given that hand-written entry, terraform init installed exactly 6.63.0 and filled in the hashes. For OpenTofu the pipeline rewrites the registry host to registry.opentofu.org in a working copy, because the two tools record the provider under different addresses.
Where the two tools differ
Terraform has an argument that removes the need for the plan-or-apply rule above. override_during = plan tells a mock to generate computed values while planning, so a plan run can assert on them:
# Terraform only. "override_during = plan" makes a mocked provider produce computed values while
# planning, so a plan run can assert on them. OpenTofu 1.12 does not accept the argument.
# Run with: terraform test -test-directory=tests-terraform-only
mock_provider "aws" {
override_during = plan
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mocked-bucket"
}
}
}
variables {
name = "clinical-docs"
environment = "dev"
data_classification = "clinical"
kms_key_arn = "arn:aws:kms:eu-west-1:111122223333:key/11111111-2222-3333-4444-555555555555"
account_id = "111122223333"
}
run "policy_is_checked_without_an_apply" {
command = plan
assert {
condition = contains(jsondecode(aws_s3_bucket_policy.this.policy).Statement[0].Resource, "arn:aws:s3:::mocked-bucket/*")
error_message = "The policy must cover the objects in the bucket."
}
}
That test passes on Terraform. OpenTofu 1.12.6 refuses the file before running anything:
Error: Unsupported argument
on tests-terraform-only/plan_time_values.tftest.hcl line 6, in mock_provider "aws":
6: override_during = plan
An argument named "override_during" is not expected here.
If your module has to be testable under both tools, keep override_during out of the shared suite and use apply runs for computed values, as the fourteen tests above do.
There is also a trap on the Terraform side. The first version of this suite set override_during = plan on the provider, gave the bucket a default ARN, and ended with an apply run. It failed. Reduced to its essentials:
# An experiment, expected to fail. With "override_during = plan" on the provider and defaults for only
# some attributes of a resource, an apply run gets one set of generated values for the remaining
# attributes while planning and a different set while applying.
# Run with: terraform test -test-directory=tests-experiments
mock_provider "aws" {
override_during = plan
mock_resource "aws_s3_bucket" {
defaults = {
arn = "arn:aws:s3:::mocked-bucket"
}
}
}
variables {
name = "clinical-docs"
environment = "dev"
data_classification = "clinical"
kms_key_arn = "arn:aws:kms:eu-west-1:111122223333:key/11111111-2222-3333-4444-555555555555"
account_id = "111122223333"
}
run "apply_after_plan_time_generation" {
command = apply
}
Error: Provider produced inconsistent final plan
When expanding the plan for aws_s3_bucket.this to include new values learned
so far during apply, provider "registry.terraform.io/hashicorp/aws" produced
an invalid new value for .acl: was cty.StringVal("24ffuuac"), but now
cty.StringVal("uaedt18z").
Terraform reported that error fourteen times, once for each computed attribute of the bucket that had no default: a random value was generated at plan time and a different one at apply time. The same experiment without the defaults block passed. The message blames the provider; the cause is the test configuration. Until that is understood, the safe rule on Terraform 1.16.3 is to use override_during = plan only in files whose runs are all plan runs.
Do the tests bite?
Fourteen green ticks say the tests pass. They do not say the tests would fail if the module were wrong. A suite of assertions that are all too loose, or that test the mock instead of the module, is green for ever. The check for that is to break the module on purpose and watch.
The pipeline applies ten defects, one at a time, to a copy of the module, runs the suite, and requires that the run fails and that a named test is what failed.
| Deliberate defect | Caught by |
|---|---|
| Default encryption changed from SSE-KMS to AES256 | default_encryption_uses_the_customer_key |
block_public_acls set to false | public_access_is_blocked_four_ways |
| Versioning set to Suspended | versioning_is_on |
| Transport statement changed from Deny to Allow | policy_denies_plain_http |
| Policy covers the bucket and not its objects | policy_denies_plain_http |
| Production check compares with “production” instead of “prod” | rejects_force_destroy_in_prod |
| Clinical check compares with “Clinical” instead of “clinical” | rejects_short_retention_for_clinical_data |
| Bucket name drops the environment | Three tests, including name_carries_environment_and_account |
| Data classification tag removed | name_carries_environment_and_account |
| Account lookup replaced by a constant | account_id_is_looked_up_when_not_given |
Ten of ten. Two of those defects are the kind that reach production: a string comparison with the wrong value silently disables a rule, the module still plans, and nothing looks wrong in review. A negative test with expect_failures is what notices.
The mutation list needed one correction, and it is instructive. The first attempt replaced a validation condition with true. Terraform rejects that outright, because a validation must refer to its own variable, so the run failed for a reason that had nothing to do with the tests. A mutation is only useful if the mutated module is still valid. The pipeline now insists that a named test fails, not merely that the command does.

A policy gate over the plan
Module tests protect the module. They say nothing about a resource someone writes directly in a root configuration, and in most estates that is where the exceptions live:
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.63.0"
}
}
}
# Enough for "plan" to run with no AWS account: nothing here is ever applied.
provider "aws" {
region = "eu-west-1"
access_key = "plan-only"
secret_key = "plan-only"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_region_validation = true
}
# What the policy gate exists to stop: a bucket for clinical data written by hand, outside the module.
resource "aws_s3_bucket" "scratch" {
bucket = "scratch-exports-prod-111122223333"
force_destroy = true
tags = {
environment = "prod"
data-classification = "clinical"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "scratch" {
bucket = aws_s3_bucket.scratch.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "scratch" {
bucket = aws_s3_bucket.scratch.id
block_public_acls = true
block_public_policy = false
ignore_public_acls = true
restrict_public_buckets = false
}
That configuration is valid Terraform and plans cleanly. The control point for it is the plan. terraform show -json turns a saved plan into a document with two parts that matter here: resource_changes, the planned values of every resource, and configuration, which records what each argument refers to. The second part is what lets a program work out which encryption configuration belongs to which bucket before any ids exist.
"""A policy gate over a Terraform or OpenTofu plan.
terraform plan -out tfplan && terraform show -json tfplan > plan.json
python check_plan.py plan.json
Reads the machine-readable plan and refuses it when an S3 bucket would be created without the
organisation's controls, or when a bucket that holds clinical data would be destroyed.
Exit code 0: no findings. 1: findings. 2: the plan could not be read.
"""
from __future__ import annotations
import json
import sys
BUCKET = "aws_s3_bucket"
ENCRYPTION = "aws_s3_bucket_server_side_encryption_configuration"
PUBLIC_ACCESS = "aws_s3_bucket_public_access_block"
VERSIONING = "aws_s3_bucket_versioning"
PUBLIC_ACCESS_SETTINGS = ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
def configured_resources(module: dict, prefix: str = "") -> dict[str, dict]:
"""Every resource in the configuration, by full address, with the module prefix it lives under."""
found = {}
for resource in module.get("resources", []):
found[prefix + resource["address"]] = {"prefix": prefix, "expressions": resource.get("expressions", {})}
for name, call in module.get("module_calls", {}).items():
found.update(configured_resources(call.get("module", {}), f"{prefix}module.{name}."))
return found
def companions(plan: dict) -> dict[str, dict[str, dict]]:
"""For each bucket address: the planned companion resources that refer to it, by type."""
config = configured_resources(plan.get("configuration", {}).get("root_module", {}))
planned = {change["address"]: change for change in plan.get("resource_changes", [])}
linked: dict[str, dict[str, dict]] = {}
for address, resource in config.items():
references = resource["expressions"].get("bucket", {}).get("references", [])
for reference in references:
target = resource["prefix"] + reference
if target in planned and planned[target]["type"] == BUCKET and address in planned:
linked.setdefault(target, {})[planned[address]["type"]] = planned[address]["change"].get("after") or {}
return linked
def first(blocks: object) -> dict:
return blocks[0] if isinstance(blocks, list) and blocks else {}
def check(plan: dict) -> list[str]:
findings: list[str] = []
linked = companions(plan)
for change in plan.get("resource_changes", []):
if change["type"] != BUCKET:
continue
address, actions = change["address"], change["change"]["actions"]
before, after = change["change"].get("before") or {}, change["change"].get("after") or {}
if "delete" in actions and (before.get("tags") or {}).get("data-classification") == "clinical":
findings.append(f"{address}: the plan destroys a bucket classified as clinical. This needs a signed-off change, not a merge.")
if "create" not in actions and "update" not in actions:
continue
tags = after.get("tags") or {}
if after.get("force_destroy") and tags.get("data-classification") == "clinical":
findings.append(f"{address}: force_destroy is on for a bucket classified as clinical.")
with_bucket = linked.get(address, {})
default = first(first(with_bucket.get(ENCRYPTION, {}).get("rule")).get("apply_server_side_encryption_by_default"))
if ENCRYPTION not in with_bucket:
findings.append(f"{address}: no default encryption is configured.")
elif default.get("sse_algorithm") != "aws:kms":
findings.append(f"{address}: default encryption is {default.get('sse_algorithm')}, not aws:kms with a customer-managed key.")
if PUBLIC_ACCESS not in with_bucket:
findings.append(f"{address}: no public access block is configured.")
else:
off = [setting for setting in PUBLIC_ACCESS_SETTINGS if with_bucket[PUBLIC_ACCESS].get(setting) is not True]
if off:
findings.append(f"{address}: public access block leaves {', '.join(off)} off.")
status = first(with_bucket.get(VERSIONING, {}).get("versioning_configuration")).get("status")
if status != "Enabled":
findings.append(f"{address}: versioning is not enabled.")
return findings
def main() -> int:
try:
with open(sys.argv[1], encoding="utf-8") as handle:
plan = json.load(handle)
if "resource_changes" not in plan:
raise ValueError("no resource_changes: is this the output of 'show -json' for a plan?")
except (IndexError, OSError, ValueError) as error:
print(f"Could not read the plan: {error}", file=sys.stderr)
return 2
findings = check(plan)
for finding in findings:
print(f"DENY {finding}")
buckets = sum(1 for change in plan["resource_changes"] if change["type"] == BUCKET)
print(f"{buckets} bucket(s) in the plan, {len(findings)} finding(s)")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
Run against the plan of the module, it prints 1 bucket(s) in the plan, 0 finding(s) and exits 0. Run against the hand-written bucket:
DENY aws_s3_bucket.scratch: force_destroy is on for a bucket classified as clinical.
DENY aws_s3_bucket.scratch: default encryption is AES256, not aws:kms with a customer-managed key.
DENY aws_s3_bucket.scratch: public access block leaves block_public_policy, restrict_public_buckets off.
DENY aws_s3_bucket.scratch: versioning is not enabled.
1 bucket(s) in the plan, 4 finding(s)
Both plans were produced in the pipeline with no AWS account. The provider block in the examples uses placeholder keys and skips credential validation, the account lookup and the metadata service, and the plan runs with -refresh=false. That works for a plan that only creates resources and reads no data sources. It is a technique for pull-request checks, not a substitute for the real plan that runs later with real credentials.
The gate has one more rule: a plan that destroys a bucket tagged as clinical is refused, whatever else it does. A real destroy plan needs existing state, which this sample never has, so that rule is tested against a hand-written plan document in the same format. In a real pipeline it would see the real plan.
A hundred lines of Python is enough to show the mechanism and to keep the sample self-contained. For a policy set that many teams share, a policy engine is the better home.
Choosing the tools
| Approach | Catches | Needs a cloud account | Limits |
|---|---|---|---|
terraform test or tofu test with mocks | Wrong module logic, broken validations | No | Says nothing about what the cloud API accepts; mocks return placeholders |
terraform test with the real provider, or Terratest | Everything, including API errors and eventual behaviour | Yes, a sandbox | Minutes per run, cost, clean-up failures, credentials in CI |
| Policy over the JSON plan: OPA with conftest, Sentinel, or a script | Rule violations anywhere in a change set, including destroys | Only as much as the plan does | Sees planned values only; unknown-until-apply values cannot be checked |
| Static scanners such as Checkov or Trivy | Known bad patterns in the source | No | Generic rules; does not evaluate your modules’ logic or your own policies without custom checks |
| Review of the plan by a person | Intent | No | Does not scale, and misses what looks plausible |
These are layers and the cheap ones go first. Mocked unit tests and a plan-level policy gate on every push; a real apply of each module into a sandbox account on a schedule or before a release; a human on the production plan. The recommendation for a team starting from nothing is the first two, because they cost seconds and need no credentials.
What this does not prove
A mock returns whatever the schema allows. It does not know that a bucket name is taken, that a KMS key policy forbids the role, that a lifecycle rule combination is rejected, or that an account-level setting overrides the bucket’s. Every test in this article would pass for a module that AWS refuses at apply time. The mocked suite proves the module’s logic; only an apply in a real account proves the configuration. Keep both, and be clear with stakeholders about which one a green build refers to.
The module encodes controls that a data protection impact assessment for clinical documents would list: encryption with a managed key, no public access, versioning, retention, TLS only. Tests and a policy gate turn those from a document into something a pipeline enforces, and the test output is evidence an auditor can read. Two cautions. A JSON plan contains every planned value, including any secrets passed as variables: treat plan files as sensitive artefacts and do not publish them from pipelines that handle real configuration. And the placeholder credentials in the examples must never be replaced with real ones in a pull-request pipeline; real plans belong in a protected stage.
Cost
The whole pipeline, both tools, ten mutations and two plans, completes in about two minutes on a hosted runner, most of it provider downloads that a cache would remove. There is no cloud spend because nothing is created. The standing cost is maintenance: tests to update when the module’s contract changes, and a pinned provider version to move forward on purpose. Set against a single incident in which a clinical bucket is created without encryption, or destroyed by a plan nobody read to the end, that is cheap.
Decision framework
| Situation | Do this |
|---|---|
| A shared module with no tests | Add a mocked test file: one valid baseline, assertions on each control, an expect_failures run per validation |
| Tests exist and are always green | Add a mutation check for the controls that matter |
| Root configurations with hand-written resources | Add a policy gate on the JSON plan |
| The module must work on Terraform and OpenTofu | Run the same test files on both in CI; keep tool-specific arguments in separate test directories |
| Regulated workload | All of the above, the test output kept as evidence, and a scheduled real apply in a sandbox account |
| A one-off experiment | A plan and a careful read is enough |
Key takeaways
- A mocked provider lets
terraform testandtofu testrun with no cloud account. The same fourteen tests passed unchanged on Terraform 1.16.3 and OpenTofu 1.12.6. - Assert on configured values in plan runs and on computed values in apply runs. That rule is portable;
override_during = planis Terraform-only, and combined with partial defaults it broke an apply run. - Prove the tests bite. Ten deliberate defects were each caught by a named test, including two validation rules disabled by a one-word typo.
- Module tests do not cover code written outside the module. A gate over the JSON plan refused a hand-written bucket with four findings.
- None of this shows that AWS will accept the configuration. Keep a real apply in a sandbox account as a later stage.
If you run module tests on both tools and have found another difference between them, leave a comment with it.
Everything described as a result ran in GitHub Actions on ubuntu-latest with Terraform 1.16.3, OpenTofu 1.12.6 and AWS provider 6.63.0 (run 43295858286), with no AWS credentials. The listings are the files in the samples repository. Nothing was applied to a real AWS account, so no claim is made that the provider or AWS accepts this configuration. The destroy plan is a hand-written fixture. OPA, Sentinel, Checkov, Trivy and Terratest are described from their documentation and were not run.
The complete sample for this article is on GitHub: nithinmohantk/blog-samples-working-repo/terraform-testing-ci. Clone it with git clone https://github.com/nithinmohantk/blog-samples-working-repo.git and open the terraform-testing-ci folder. Its README says how to run it and what has been verified.
Glossary
- Mock provider— A stand-in for a real provider in tests. It has the real schema and makes no API calls.
- Computed attribute— A value the provider supplies after creating a resource, such as an ARN or an id.
- Run block— One test case in a test file: a plan or an apply, with assertions.
- expect_failures— A list of objects whose checks must fail for the run to pass. Used for negative tests.
- Mutation check— Introducing a deliberate defect to confirm that a test fails.
- Dependency lock file—
.terraform.lock.hcl: the exact provider versions and hashes a configuration uses. - JSON plan— The machine-readable form of a saved plan, produced by
show -json. - Policy gate— An automated check that refuses a change when it breaks a rule.
References & Further Reading
- → Terraform: tests— Test files, run blocks, assertions and expect_failures
- → Terraform: mocks— mock_provider, defaults, overrides and override_during
- → OpenTofu: tofu test— The test command and mock providers in OpenTofu
- → Terraform: JSON output format— The structure of show -json output
- → Terraform: dependency lock file— How provider versions and hashes are pinned
- → Open Policy Agent: Terraform— Writing plan policies in Rego
- → Sample code for this article— The terraform-testing-ci folder on GitHub
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.