Skip to content
Infrastructure as Code iac testing 4 min read

Native Test Framework

Terraform 1.6 introduced a first-class testing framework built directly into the CLI, so you no longer need an external language like Go to verify a module behaves correctly. Tests live in .tftest.hcl files written in the same HCL you already know, they run real plan and apply operations against your configuration, and they assert on outputs, resource attributes, and computed values. Because the framework can provision and then automatically destroy ephemeral infrastructure, it gives you confidence that a module actually works — not just that it parses. OpenTofu ships the identical tofu test command and .tftest.hcl format.

How the framework is structured

A test file is a collection of run blocks. Each run executes the configuration under test and then evaluates one or more assert blocks. Runs execute top to bottom within a file, sharing state, so a later run sees the resources created by an earlier one. By default a run performs a full apply, but you can switch it to a plan-only check.

Test files are discovered automatically in the root module directory and in a tests/ subdirectory. Running the suite is a single command:

terraform test

Terraform initializes the configuration, executes every run block, reports pass or fail per assertion, and — for apply runs — tears down everything it created at the end, in reverse order.

Plan tests vs apply tests

The command argument on a run block selects the mode. A plan run is fast and offline-friendly: it evaluates expressions and validation logic without creating anything, which is ideal for checking input validation, computed locals, and conditional logic. An apply run actually provisions resources, which is slower and may incur cost, but lets you assert on attributes that are only known after creation.

Aspectcommand = plancommand = apply
Creates real resourcesNoYes
SpeedFastSlower
Asserts on computed (known-after-apply) valuesNoYes
Cost / credentialsUsually noneReal provider calls
Auto-cleanupNothing to cleanDestroys at end of run

Default to plan runs for logic and validation, and reserve apply runs for the handful of behaviors that genuinely require live resources. A suite that is mostly plan tests stays fast and cheap.

A worked example

Consider a small module that creates an S3 bucket with versioning and exposes a couple of outputs.

# main.tf
variable "bucket_name" {
  type = string

  validation {
    condition     = length(var.bucket_name) >= 3
    error_message = "bucket_name must be at least 3 characters."
  }
}

variable "force_destroy" {
  type    = bool
  default = false
}

resource "aws_s3_bucket" "this" {
  bucket        = var.bucket_name
  force_destroy = var.force_destroy
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = "Enabled"
  }
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

output "versioning_status" {
  value = aws_s3_bucket_versioning.this.versioning_configuration[0].status
}

The test file lives in tests/bucket.tftest.hcl. The first run is a plan test that checks input validation logic; the second is an apply test that asserts on a real resource attribute and an output.

# tests/bucket.tftest.hcl
variables {
  bucket_name = "devcraftly-test-bucket"
}

run "versioning_is_enabled_in_plan" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning must be configured as Enabled."
  }
}

run "bucket_has_correct_name" {
  command = apply

  assert {
    condition     = aws_s3_bucket.this.bucket == "devcraftly-test-bucket"
    error_message = "Bucket name did not match the requested name."
  }

  assert {
    condition     = output.versioning_status == "Enabled"
    error_message = "Output versioning_status should report Enabled."
  }
}

Running the suite produces per-run, per-assertion results:

terraform test

Output:

tests/bucket.tftest.hcl... in progress
  run "versioning_is_enabled_in_plan"... pass
  run "bucket_has_correct_name"... pass
tests/bucket.tftest.hcl... teardown
tests/bucket.tftest.hcl... pass

Success! 2 passed, 0 failed.

Per-run variables and validation failures

A top-level variables block sets defaults for every run, and each run may override them to exercise a different scenario. You can also assert that an invalid input is rejected with expect_failures, pointing at the checkable object — here the variable’s validation block.

run "rejects_short_name" {
  command = plan

  variables {
    bucket_name = "ab"
  }

  expect_failures = [
    var.bucket_name,
  ]
}

Mocking providers

Apply tests need a real provider, which means credentials and cost. To test resource shapes without ever calling a cloud API, Terraform 1.7+ supports mock_provider. Mocked providers return fake-but-schema-valid data for computed attributes, so an apply run completes instantly with no network access. You can also pin specific attributes with override_resource or override_data so assertions remain deterministic.

mock_provider "aws" {}

run "name_is_passed_through_with_mock" {
  command = apply

  override_resource {
    target = aws_s3_bucket.this
    values = {
      arn = "arn:aws:s3:::devcraftly-test-bucket"
    }
  }

  assert {
    condition     = output.bucket_arn == "arn:aws:s3:::devcraftly-test-bucket"
    error_message = "bucket_arn output should expose the resource ARN."
  }
}

Mocking is the bridge between cheap plan tests and expensive live apply tests: you get apply-time semantics and computed values without provisioning anything real.

Best Practices

  • Keep tests in a dedicated tests/ directory so they are easy to find and run separately.
  • Prefer command = plan for logic, validation, and conditional checks; it is fast and needs no credentials.
  • Use mock_provider to exercise apply behavior without real cloud resources, reserving live applies for true end-to-end coverage.
  • Write a clear error_message on every assert so failures explain what was expected.
  • Use expect_failures to lock in your variable validation rules and precondition logic.
  • Run terraform test in CI on every pull request so module regressions are caught before merge.
  • Reuse a top-level variables block for shared defaults and override per run to cover edge cases.
Last updated June 14, 2026
Was this helpful?