🧭 How toService ManagementIntermediate✨ AI-assisted

How to Unit Test Terraform Modules With Mock Providers

WittyTech Daily··5 min read
#terraform#opentofu#iac-testing#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 test shipped in Terraform 1.6, per the Terraform tests docs.
  • mock_provider arrived in Terraform 1.7, per the mocking docs.
  • override_during, used in step 4, is in Terraform 1.11, which also made -junit-xml generally available (see the 1.11 changelog).
  • OpenTofu announced provider mocking for tofu test in 1.8 and documents the same mock_provider, mock_resource and mock_data blocks 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 defaults for anything that feeds a regex or an ARN check.
  • Repeated nested blocks share one set of mock values. You can't give each replica block in aws_dynamodb_table its own value.
  • The default command is apply. Leave it off while a real provider block 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 with providers = { 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.

← More in Service Management