How to Unit Test Terraform Modules With Mock Providers
Your Terraform module has a naming convention, a conditional and a validation rule, and the only test is applying it to a sandbox account and watching for red. By the end of this guide you will have a .tftest.hcl suite that checks that logic using Terraform mock providers: no credentials, no real resources, a few seconds per run, and a CI job to match.
Which Terraform and OpenTofu versions support mock providers
terraform testshipped in Terraform 1.6, per the Terraform tests docs.mock_providerarrived in Terraform 1.7, per the mocking docs.override_during, used in step 4, is in Terraform 1.11, which also made-junit-xmlgenerally available (see the 1.11 changelog).- OpenTofu announced provider mocking for
tofu testin 1.8 and documents the samemock_provider,mock_resourceandmock_datablocks in its test command docs.
Step 1: Write a module with logic worth testing
This small S3 module has four behaviors a test can pin down: a bucket name convention, a versioning switch, an input validation rule and an output built from a computed value.
# versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
# main.tf
variable "name_prefix" {
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,30}$", var.name_prefix))
error_message = "name_prefix must be 3-30 lowercase letters, digits or hyphens."
}
}
variable "environment" {
type = string
}
variable "versioning_enabled" {
type = bool
default = true
}
resource "aws_s3_bucket" "this" {
bucket = "${var.name_prefix}-${var.environment}-data"
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning_enabled ? "Enabled" : "Suspended"
}
}
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
}
output "console_url" {
value = "https://s3.console.aws.amazon.com/s3/buckets/${aws_s3_bucket.this.id}"
}
Put your tests in a tests/ folder inside the module. terraform test looks there by default, plus any test files in the module root.
Step 2: Use Terraform mock providers for a plan-only test
Create tests/bucket.tftest.hcl. The mock_provider block replaces the real aws provider, and command = plan stops each run before anything is created.
# tests/bucket.tftest.hcl
mock_provider "aws" {}
variables {
name_prefix = "acme"
environment = "dev"
}
run "bucket_name_follows_convention" {
command = plan
assert {
condition = aws_s3_bucket.this.bucket == "acme-dev-data"
error_message = "Expected <prefix>-<env>-data, got ${aws_s3_bucket.this.bucket}"
}
assert {
condition = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
error_message = "ManagedBy tag is missing"
}
}
run "versioning_is_on_by_default" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Versioning should default to Enabled"
}
}
run "versioning_can_be_suspended" {
command = plan
variables {
versioning_enabled = false
}
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Suspended"
error_message = "versioning_enabled = false should suspend versioning"
}
}
run "public_access_is_blocked" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_policy == true
error_message = "Public bucket policies must be blocked"
}
}
Run it from the module directory:
terraform init -backend=false
terraform test -verbose
You still need terraform init: the mock returns the same schema as the real provider, so the plugin has to be installed. It never needs credentials. -verbose prints the plan for each run block, which helps when an assertion fails.
Step 3: Assert that bad input is rejected
Validation rules deserve a test too. expect_failures makes a run pass only if the named object fails its check.
run "rejects_uppercase_prefix" {
command = plan
variables {
name_prefix = "ACME"
}
expect_failures = [var.name_prefix]
}
If someone loosens the regex later, this run starts failing, which is the point.
Step 4: Mock computed values at plan time
The console_url output depends on the bucket id, a computed value. By default, mocks generate values during apply, so at plan time id is unknown and an assertion on the output can't be evaluated. Set override_during = plan and give the mock a realistic default. Replace the empty mock_provider line from step 2 with this:
mock_provider "aws" {
override_during = plan
mock_resource "aws_s3_bucket" {
defaults = {
id = "acme-dev-data"
}
}
}
run "console_url_points_at_the_bucket" {
command = plan
assert {
condition = output.console_url == "https://s3.console.aws.amazon.com/s3/buckets/acme-dev-data"
error_message = "console_url should link to the bucket, got ${output.console_url}"
}
}
This requires Terraform 1.11 or later. mock_resource and mock_data also accept their own override_during, which wins over the provider-level setting.
Step 5: Run Terraform module tests in CI
Because nothing talks to a cloud, the job needs no secrets and can run on every pull request.
# .github/workflows/module-tests.yml
name: module-tests
on:
pull_request:
jobs:
terraform-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: modules/s3-data-bucket
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.14.6"
- run: terraform init -backend=false
- run: terraform test -junit-xml=test-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: terraform-test-results
path: modules/s3-data-bucket/test-results.xml
Pin terraform_version (the setup-terraform action also accepts version ranges) so a new release doesn't change your results unannounced. A failing run fails the job.
Run the same suite with OpenTofu
The test file above uses the same blocks in OpenTofu, so swap the tooling and keep the file. If a directory contains both main.tftest.hcl and main.tofutest.hcl, OpenTofu loads the .tofutest.hcl one, which lets you fork a test only when the two tools diverge.
- uses: opentofu/setup-opentofu@v2
with:
tofu_version: "<your-tofu-version>"
- run: tofu init -backend=false
- run: tofu test
OpenTofu's test command docs don't mention override_during, so run tofu test against step 4 before assuming it behaves the same.
Gotchas
- Plan-only tests can't see provider-side failures. Name collisions, IAM denials and API-level validation only show up against a real account. Keep a few apply-based tests on a schedule.
- Mocks generate computed attributes only. Required attributes must still be set in your configuration.
- Generated values rarely match real formats. Strings are random 8-character alphanumerics, so set realistic
defaultsfor anything that feeds a regex or an ARN check. - Repeated nested blocks share one set of mock values. You can't give each
replicablock inaws_dynamodb_tableits own value. - The default
commandisapply. Leave it off while a realproviderblock is in play and you create real resources. - Mixing real and mocked providers needs an alias. Declare
mock_provider "aws" { alias = "fake" }and pick it in a run withproviders = { aws = aws.fake }.
The GPU node pools, networks and IAM roles under your AI workloads come from the same shared modules, so tests that finish in seconds are what let you change them without hoping nothing downstream breaks.