Testing: terraform test, mock_provider, and Terratest

K
Kai··5 min read

The pipeline in the previous article handles running Terraform safely, but it doesn't answer the question: is this code correct? A module that composes a bucket name wrong, a validation that misses a case — those logic errors get past validate and tflint, only surfacing on apply, sometimes in prod. Terraform has its own testing framework to catch them early. This article uses terraform test with a mock provider to test without standing up infrastructure, and introduces Terratest for a deeper level.

Goal

Write and run tests with terraform test using .tftest.hcl files, use mock_provider to test without spending on infrastructure, check both the success case and the case that must fail, and know when you need Terratest.

Why you need separate tests

validate checks syntax, tflint/Trivy check conventions and security. None of them check logic: if your module composes a bucket name from project and environment, how are you sure it composes the right tf-series-dev-data and not tf-series--data? If there's a validation, how are you sure it actually blocks bad values? This is the job of testing — asserting expected behavior with runnable assertions.

terraform test: the .tftest.hcl file

The terraform test framework is GA since Terraform 1.6. Tests are written in .tftest.hcl files (usually placed in a tests/ directory), made of run blocks — each run executes a command (plan or apply) with a set of variables, then checks with assert.

Take a configuration with logic worth testing: composing a bucket name and validating the environment.

variable "project" { type = string }
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment phải là dev, staging hoặc prod."
  }
}

locals {
  bucket_name = "${var.project}-${var.environment}-data"
}

resource "aws_s3_bucket" "data" {
  bucket = local.bucket_name
  tags   = { Environment = var.environment }
}

The test file tests/bucket.tftest.hcl:

mock_provider "aws" {}

run "bucket_name_is_composed" {
  command = plan
  variables {
    project     = "tf-series"
    environment = "dev"
  }
  assert {
    condition     = aws_s3_bucket.data.bucket == "tf-series-dev-data"
    error_message = "Tên bucket ghép sai: ${aws_s3_bucket.data.bucket}"
  }
}

run "tag_matches_env" {
  command = plan
  variables {
    project     = "tf-series"
    environment = "prod"
  }
  assert {
    condition     = aws_s3_bucket.data.tags["Environment"] == "prod"
    error_message = "Tag Environment không khớp."
  }
}

run "invalid_env_rejected" {
  command = plan
  variables {
    project     = "tf-series"
    environment = "production"   # invalid -> must fail validation
  }
  expect_failures = [var.environment]
}

mock_provider: testing without real infrastructure

The line mock_provider "aws" {} is the key point. Since Terraform 1.7, mock_provider simulates the data the provider returns, so terraform test runs without calling AWS, without creating resources, without needing credentials. The test becomes a pure-logic unit test, running in milliseconds, safe to run on every PR. The three kinds of assertion in the example: the first two runs check computed values (command = plan is enough for Terraform to compute the value without applying), the third run uses expect_failures to assert that validation must block environment = "production".

Run:

$ terraform test
tests/bucket.tftest.hcl... in progress
  run "bucket_name_is_composed"... pass
  run "tag_matches_env"... pass
  run "invalid_env_rejected"... pass
tests/bucket.tftest.hcl... tearing down
tests/bucket.tftest.hcl... pass

Success! 3 passed, 0 failed.

Three tests pass. expect_failures inverts the expectation: the test passes because validation blocked exactly as expected — this is how you verify that the guardrail from Article 9 really blocks, not just hope it blocks.

What a failing test looks like

To see the test framework catch a real error, change an assertion to a wrong expectation (e.g. expecting the name tf-series-WRONG):

$ terraform test
  run "bucket_name_is_composed"... fail

Error: Test assertion failed
Tên bucket ghép sai: tf-series-dev-data

Failure! 2 passed, 1 failed.

The failing test prints the very error_message you wrote, along with the actual value (tf-series-dev-data) — enough to know where it's wrong immediately. In CI, a failing test breaks the job, blocks the merge. This is the final checkpoint for correctness that Article 18 was missing.

command = plan and command = apply

By default you should use command = plan for logic tests — fast, creates nothing, combined with mock_provider makes a unit test. When you need to check real behavior (whether the resource stood up has the right AWS-assigned attributes, whether the endpoint is alive), use command = apply: Terraform actually stands up during the test then asserts on the real result, and cleans up itself (tearing down) when done. This kind is slower and costs money, reserved for integration tests run less often than unit tests.

   fast, many            ┌───────────────────────────┐
        ▲                │ unit: plan + mock_provider │  logic, no infrastructure
        │                ├───────────────────────────┤
        │                │ integration: real apply    │  real behavior, with cleanup
        │                ├───────────────────────────┤
   slow, few             │ Terratest (Go)             │  end-to-end, deep checks
                         └───────────────────────────┘

Terratest: integration testing in Go

terraform test is enough for most needs. When you need to check deeper — stand up infrastructure then call HTTP into an endpoint, SSH into an instance, check runtime state — Terratest (Gruntwork's Go library) is the long-standing choice. You write tests in Go: the code calls terraform apply, runs arbitrary checks on the real infrastructure, then terraform destroy at the end. The tradeoff for that power is having to write Go, and each test stands up and tears down real infrastructure so it's slow. Rule: prefer terraform test for unit/integration within Terraform, use Terratest when you need checks beyond what Terraform can see.

Wrap-up

terraform test (GA 1.6) checks configuration logic with .tftest.hcl files: the run block runs plan/apply with variables, assert asserts the result, expect_failures asserts that a validation must block. mock_provider (1.7) lets tests run without real AWS — fast, safe for every PR. A failing test prints the error_message along with the actual value, blocks the merge in CI. command = apply is used for integration tests that stand up for real then assert; Terratest (Go) is for deep end-to-end testing beyond Terraform's reach.

By now we've gone all the way: fundamentals, state, variables, modules, multi-environment, lifecycle, CI/CD, testing. The final article assembles everything into a capstone — standing up a complete multi-environment infrastructure (VPC, ALB, ASG, RDS, S3) with modules through a pipeline, then a clean teardown, closing the series with a roadmap for what to learn next.