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 testuses HCL test files withrunblocks andassertconditions.- Plan-mode tests with mock providers are fast, need no credentials and catch most module regressions.
- Test validation rules with
expect_failuresand 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
- Terraform tests: https://developer.hashicorp.com/terraform/language/tests
- Mocks in Terraform tests: https://developer.hashicorp.com/terraform/language/tests/mocking
- terraform test command: https://developer.hashicorp.com/terraform/cli/commands/test
- Custom condition checks and validation: https://developer.hashicorp.com/terraform/language/expressions/custom-conditions
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.