linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/lessons/tf-production-03-native-tests

lesson ── terraform-production ── ~16 мин ── 5 шагов

Native tests, .tftest.hcl and assert

Terraform 1.6 ships a built-in test runner. *.tftest.hcl files describe scenarios through run blocks and assert checks. In this lesson you build a bucket module, write tests for it in command = plan mode (fast, no apply; the provider still comes up and reaches out to the endpoint, which is safe on LocalStack) and in command = apply.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Write a minimal bucket module

    bash
    cd /home/student/tf-tests/modules/bucket
    cat > main.tf <<'EOF'
    resource "aws_s3_bucket" "this" {
      bucket = var.name
      tags = merge(
        { ManagedBy = "terraform" },
        var.tags,
      )
    }
    EOF
    cat > variables.tf <<'EOF'
    variable "name" {
      type = string
      validation {
        condition     = length(var.name) >= 3 && length(var.name) <= 63
        error_message = "bucket name must be 3-63 chars"
      }
    }
    variable "tags" {
      type    = map(string)
      default = {}
    }
    EOF
    cat > outputs.tf <<'EOF'
    output "id" {
      value = aws_s3_bucket.this.id
    }
    EOF

    The module is ready. It has one input name (with validation), optional tags, and an id output.

    ✓ The bucket module is written. Now we'll cover it with tests.

  2. 02

    First test, command = plan

    Tests sit next to the module. The convention is a tests/ directory:

    bash
    mkdir -p /home/student/tf-tests/modules/bucket/tests
    cat > /home/student/tf-tests/modules/bucket/tests/naming.tftest.hcl <<'EOF'
    variables {
      name = "my-test-bucket"
    }
    run "bucket_name_propagates" {
      command = plan
      assert {
        condition     = aws_s3_bucket.this.bucket == "my-test-bucket"
        error_message = "var.name does not reach the resource"
      }
    }
    run "managedby_tag_set" {
      command = plan
      assert {
        condition     = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
        error_message = "ManagedBy tag missing"
      }
    }
    run "custom_tags_merged" {
      command = plan
      variables {
        tags = { Owner = "ci" }
      }
      assert {
        condition     = aws_s3_bucket.this.tags["Owner"] == "ci"
        error_message = "Custom tag not merged"
      }
    }
    EOF

    Run it:

    bash
    cd /home/student/tf-tests/modules/bucket
    terraform init -backend=false
    terraform test

    You should see "3 passed, 0 failed". All three tests run in plan mode, so no cloud is needed.

    ✓ Three tests passed. Plan-only, fast.

  3. 03

    expect_failures, a test on validation

    Validation has to fire. Here is a test for it:

    bash
    cat > /home/student/tf-tests/modules/bucket/tests/validation.tftest.hcl <<'EOF'
    run "rejects_too_short_name" {
      command = plan
      variables {
        name = "ab"
      }
      expect_failures = [
        var.name,
      ]
    }
    run "rejects_too_long_name" {
      command = plan
      variables {
        name = "this-name-is-way-too-long-and-should-fail-because-its-over-63-characters-easy"
      }
      expect_failures = [
        var.name,
      ]
    }
    EOF
    terraform test

    You should see "5 passed". These two tests pass because validation really did fail, which is exactly what we expected.

    ✓ Validation is checked. The tests catch the inverse cases.

  4. 04

    command = apply on LocalStack

    Plan-only sees only the static side. To check that the resource is really created (for example, that the id matches the name), you need apply. The provider in the test must point at LocalStack.

    bash
    cat > /home/student/tf-tests/modules/bucket/tests/apply.tftest.hcl <<'EOF'
    provider "aws" {
      region                      = "us-east-1"
      access_key                  = "test"
      secret_key                  = "test"
      s3_use_path_style           = true
      skip_credentials_validation = true
      skip_metadata_api_check     = true
      skip_requesting_account_id  = true
      endpoints {
        s3  = "http://localstack:4566"
        iam = "http://localstack:4566"
        sts = "http://localstack:4566"
      }
    }
    run "real_apply_creates_bucket" {
      command = apply
      variables {
        name = "test-apply-bucket"
      }
      assert {
        condition     = aws_s3_bucket.this.id == "test-apply-bucket"
        error_message = "id should equal name for s3 bucket"
      }
      assert {
        condition     = length(output.id) > 0
        error_message = "output.id is empty"
      }
    }
    EOF
    terraform test

    You should see "6 passed". The apply test really created a bucket in LocalStack and removed it right after, the runner does a destroy at the end.

    ✓ The apply test passed. The bucket was created and torn down within a single test.

    The same thing on OpenTofu

    OpenTofu keeps the CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back up the state and do a run on a feature branch, the differences cluster in the newer features (variables in backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  5. 05

    Run only one test file

    While developing a test there is no point running all the others. Filter:

    bash
    terraform test -filter=tests/validation.tftest.hcl

    Runs only the validation tests and skips the rest. A time saver during long apply scenarios.

    Several filters:

    bash
    terraform test \
      -filter=tests/naming.tftest.hcl \
      -filter=tests/validation.tftest.hcl

    Just these two files, the apply test will not run.

    ✓ Filter works. In a CI pipeline this gives you fast PR checks separately from the long integration tests.

    When a test is not needed

    A test is a commitment. Each one has to be maintained. Antipatterns:

    1. Pass-through test. With output "id" { value = ... } there is nothing to test in asserting the output equals the ARN; if that broke, plan would not run at all.
    2. Test on the provider. "After apply the bucket really gets created" is the AWS provider's job, not a fault in your code.
    3. Test on every attribute. A module has 20 variables, testing that each one "arrives" is copy-paste. Test one happy path and one or two validation cases.

    What to test:

    • Complex expressions (merge, format, conditional).
    • Validation (expect_failures).
    • Business invariant ("all names start with the team- prefix").
    • Refactoring (no diff after refactor, golden plan).

    See iac-testing-theory.

    • → tftest.hcl in full
    • → What to test, what not to

Что ты узнал

.tftest.hcl files live next to the module. run is a scenario, assert is a check. command = plan for fast unit tests, command = apply for integration tests. expect_failures for inverse checks (that validation fired).

команды

  • terraform testrun all *.tftest.hcl.
  • terraform test -filter=tests/foo.tftest.hclone file only, handy while developing a test.
  • terraform test -verboseshows the plan output of each run, for debugging.

концепции

  • · tests/ is a convention, not a requirement; you can put the files next to the .tf
  • · command=plan does not apply, but the provider still starts (refresh, data sources, credential validation); fully offline only with mock_provider
  • · expect_failures = [var.x] makes the test pass when validation fired

← предыдущий

for_each over a module: N instances from one block

следующий →

Large-scale state, breaking up the monolith

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies