Testing Infrastructure Code: terraform test, Mock Providers and a Policy Gate in CI

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_destroy in 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.
Three stages: unit tests with a mocked provider, a mutation check that applies ten deliberate defects, and a policy gate over the JSON plan. A table shows which question each answers, and that none of them shows whether AWS will accept the configuration.
Three checks that need no cloud account, and the one question none of them answers.

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 plan run 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 in expect_failures passes 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 variables block 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 store

On 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 defectCaught by
Default encryption changed from SSE-KMS to AES256default_encryption_uses_the_customer_key
block_public_acls set to falsepublic_access_is_blocked_four_ways
Versioning set to Suspendedversioning_is_on
Transport statement changed from Deny to Allowpolicy_denies_plain_http
Policy covers the bucket and not its objectspolicy_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 environmentThree tests, including name_carries_environment_and_account
Data classification tag removedname_carries_environment_and_account
Account lookup replaced by a constantaccount_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.

Results table: 14 of 14 unit tests pass on Terraform 1.16.3 and OpenTofu 1.12.6; a test using override_during is refused by OpenTofu; override_during with partial defaults fails an apply run; ten of ten deliberate defects are caught; the policy gate passes the module plan and refuses a hand-written bucket with four findings.
Results of one pipeline run. The two tools agree on the portable suite and differ on one argument.

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

ApproachCatchesNeeds a cloud accountLimits
terraform test or tofu test with mocksWrong module logic, broken validationsNoSays nothing about what the cloud API accepts; mocks return placeholders
terraform test with the real provider, or TerratestEverything, including API errors and eventual behaviourYes, a sandboxMinutes per run, cost, clean-up failures, credentials in CI
Policy over the JSON plan: OPA with conftest, Sentinel, or a scriptRule violations anywhere in a change set, including destroysOnly as much as the plan doesSees planned values only; unknown-until-apply values cannot be checked
Static scanners such as Checkov or TrivyKnown bad patterns in the sourceNoGeneric rules; does not evaluate your modules’ logic or your own policies without custom checks
Review of the plan by a personIntentNoDoes 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.

⚖️
SECURITY AND DATA PROTECTION

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

SituationDo this
A shared module with no testsAdd a mocked test file: one valid baseline, assertions on each control, an expect_failures run per validation
Tests exist and are always greenAdd a mutation check for the controls that matter
Root configurations with hand-written resourcesAdd a policy gate on the JSON plan
The module must work on Terraform and OpenTofuRun the same test files on both in CI; keep tool-specific arguments in separate test directories
Regulated workloadAll of the above, the test output kept as evidence, and a scheduled real apply in a sandbox account
A one-off experimentA plan and a careful read is enough

Key takeaways

  • A mocked provider lets terraform test and tofu test run 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 = plan is 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.

ℹ️
HOW THIS WAS CHECKED

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.

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


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.