Our platform team maintains about twenty shared Terraform modules: VPC, ECS service, RDS instance, S3 bucket with our standard policies, and so on. Product teams consume them by version. In one quarter we shipped two module releases that broke consumers: a renamed output and a default that turned off encryption for a new bucket type. Both would have been caught by a test that took seconds to write.

Terraform has a native test framework, terraform test, with test files in HCL. This post shows how we structured tests for our modules: fast unit-style tests in plan mode with mocked providers on every pull request, and slower integration tests that create real resources nightly.

The basics

Test files end in .tftest.hcl and live in the module root or a tests/ directory. Each file contains one or more run blocks. A run executes a plan or apply of the module with given variables, then checks assert conditions against the result.

modules/s3-bucket/
  main.tf
  variables.tf
  outputs.tf
  tests/
    defaults.tftest.hcl
    validation.tftest.hcl
    integration.tftest.hcl
terraform init
terraform test                      # all test files
terraform test -filter=tests/defaults.tftest.hcl

Resources created by apply-mode runs are destroyed automatically at the end of the test file, in reverse order.

Plan-mode tests: fast and free

Most module logic is about computing the right arguments from inputs: names, tags, encryption settings, conditional resources. A plan is enough to check those, and it creates nothing.

# tests/defaults.tftest.hcl
variables {
  name        = "reports"
  environment = "staging"
}

run "encryption_is_on_by_default" {
  command = plan

  assert {
    condition     = aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "aws:kms"
    error_message = "Buckets must default to SSE-KMS"
  }
}

run "public_access_is_blocked" {
  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 public access block settings must be true"
  }
}

run "name_follows_convention" {
  command = plan

  assert {
    condition     = aws_s3_bucket.this.bucket == "acme-staging-reports"
    error_message = "Bucket name must be acme-<environment>-<name>"
  }
}

Attributes known only after apply, such as generated ARNs and IDs, cannot be asserted in plan mode. Assert on what your module controls.

Mock providers: no credentials needed

A plan still calls the provider, which needs credentials and makes data source lookups against a real account. Terraform 1.7 added mock providers for tests, which return fake values instead of calling the cloud:

# tests/defaults.tftest.hcl
mock_provider "aws" {
  mock_data "aws_caller_identity" {
    defaults = {
      account_id = "111122223333"
    }
  }
  mock_data "aws_region" {
    defaults = {
      name = "eu-west-1"
    }
  }
}

With the mock in place, the plan-mode tests run in CI without any AWS credentials, in a few seconds per module. That was the deciding factor for running them on every pull request. Mocked runs can even use command = apply, because no real resources are created; computed values are filled with generated fakes, which lets you test outputs.

Testing validation rules

Variable validation is part of the module's contract. Test that bad input fails, using expect_failures:

# tests/validation.tftest.hcl
mock_provider "aws" {}

run "rejects_unknown_environment" {
  command = plan

  variables {
    name        = "reports"
    environment = "qa-temp"
  }

  expect_failures = [var.environment]
}
# variables.tf
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

The run passes only if validation for var.environment fails.

Testing outputs, the contract consumers rely on

One of our breaking releases renamed an output. A test pins the outputs consumers use:

mock_provider "aws" {}

run "outputs_exist" {
  command = apply   # safe: provider is mocked

  assert {
    condition     = output.bucket_arn != null && output.bucket_name != null
    error_message = "bucket_arn and bucket_name outputs are part of the public interface"
  }
}

Integration tests: real applies, nightly

Plans and mocks cannot prove that AWS accepts the configuration, that IAM policies work or that two resources interact correctly. For that we run apply-mode tests against a dedicated sandbox account:

# tests/integration.tftest.hcl
provider "aws" {
  region = "eu-west-1"
}

variables {
  environment = "dev"
  # name is passed from CI: terraform test -var="name=tftest-$GITHUB_RUN_ID"
}

run "create_bucket" {
  command = apply

  assert {
    condition     = startswith(aws_s3_bucket.this.arn, "arn:aws:s3:::")
    error_message = "Bucket was not created"
  }
}

run "write_object_with_default_encryption" {
  command = apply
  module {
    source = "./tests/helpers/put-object"
  }
  variables {
    bucket = run.create_bucket.bucket_name
  }

  assert {
    condition     = output.server_side_encryption == "aws:kms"
    error_message = "Objects must be encrypted with KMS by default"
  }
}

run.create_bucket.bucket_name references an output from an earlier run, and the helper module performs a real object upload. The bucket name gets a unique suffix from the CI run ID, passed on the command line, so parallel runs never collide. Terraform destroys everything at the end; we also run a nightly sweeper that deletes anything tagged tftest older than a day, in case a run was killed.

What a failing test looks like

When the bucket encryption default regressed in a branch, the plan-mode suite failed in about eight seconds with a message any reviewer could understand:

tests/defaults.tftest.hcl... in progress
  run "encryption_is_on_by_default"... fail
╷
│ Error: Test assertion failed
│
│   on tests/defaults.tftest.hcl line 9, in run "encryption_is_on_by_default":
│    9:     condition     = aws_s3_bucket_server_side_encryption_configuration.this.rule[0]...
│
│ Buckets must default to SSE-KMS
╵
tests/defaults.tftest.hcl... tearing down
tests/defaults.tftest.hcl... fail

Failure! 2 passed, 1 failed.

Write error_message values as statements of the rule, not descriptions of the failure, so the output explains what the module promises.

Adding tests to an existing module

You do not need full coverage on day one. For each existing module we started with three tests: one for each security default, one for naming, and one that pins the outputs. Then every bug fix adds a test that would have caught it. After a quarter, the modules with the most changes also had the most tests, which is where they matter.

CI layout and cost

Suite When Credentials Typical duration Cost
Plan-mode with mocks Every pull request None 5-20 s per module None
Apply-mode integration Nightly and before release tags Sandbox account role (OIDC) 2-12 min per module Small; resources exist for minutes

Modules with expensive resources, such as RDS, run integration tests weekly instead of nightly.

What we do not test

  • Provider behaviour. We trust the AWS provider to create a bucket when asked.
  • Every attribute. Tests cover security defaults, naming, conditional logic and the public interface.
  • Consumer stacks. Those are covered by each team's own plan in CI.

Key takeaways

  • terraform test uses HCL test files with run blocks and assert conditions.
  • Plan-mode tests with mock providers are fast, need no credentials and catch most module regressions.
  • Test validation rules with expect_failures and pin the outputs consumers depend on.
  • Apply-mode integration tests against a sandbox prove real behaviour; run them less often.
  • Back automatic teardown with a sweeper for interrupted runs.

References