Mastering Terraform Test Block

In today’s blog post, we will learn Terraform test block used for integration or unit testing and validations. Introduced in Terraform v1.5, the test and run blocks allow you to write detailed unit, integration, and even basic end-to-end tests directly in HCL. This removes the need for external frameworks for many common testing scenarios. This blog post will cover the test and run blocks, its important components, and how to use it to build automated tests cases for your Terraform configurations.

What is the Terraform test Block?

The test block is the top-level container for defining a suite of tests within a .tftest.hcl (or .tftest.json) file. It acts as a logical grouping for related test scenarios, each represented by one or more run blocks.

When you execute the terraform test command, Terraform discovers all .tftest.hcl files in your working directory and runs the tests defined within them.

Key Features of the test block:

  • File Naming Convention: Test files must end with .tftest.hcl (or .tftest.json) to be discovered by terraform test command.
  • Isolation: Each test block creates an ephemeral, isolated testing environment. This means a dedicated working directory, a separate .terraform folder, and its own state file, which is destroyed after the test run. This prevents tests from interfering with each other or your main configuration.
  • Declarative: Tests are written in HCL, making them readable, version-controlled, and easily integrated into existing Terraform workflows.

Syntax of the test Block

A test block typically contains:

  1. Optional Input Variables: To customize your test.
  2. Optional Provider Configurations: Including the ability to mock providers.
  3. One or more run blocks: Each representing a specific test scenario or phase.
# example.tftest.hcl

test "my_first_test_suite" {
  # 1. Optional: Define input variables for the configuration being tested
  variables {
    region      = "us-east-1"
    environment = "test"
  }

  # 2. Optional: Configure providers for this test run.
  # This is crucial for mocking or specific test credentials.
  provider "aws" {
    region = var.region
    # In integration tests, you had provide real credentials or roles here.
    # For unit tests, you might mock.
  }

  # provider "aws" "mock" {
  #   # This is where you would configure mocking for unit tests.
  #   # Requires provider-specific mocking capabilities or a mock provider.
  # }

  # 3. One or more 'run' blocks define individual test scenarios
  run "initial_apply_success" {
    command = apply # Perform a terraform apply

    # Assertions to verify the successful application
    assert {
      condition     = aws_s3_bucket.test_bucket.id != null
      error_message = "S3 bucket ID should not be null after apply."
    }

    assert {
      condition     = aws_s3_bucket.test_bucket.bucket == "my-test-bucket-12345"
      error_message = "S3 bucket name should match the expected value."
    }
  }

  run "plan_no_changes" {
    command = plan # Perform a terraform plan after initial apply

    # Assert that no changes are planned after the initial apply
    assert {
      condition     = run.initial_apply_success.exit_code == 0
      error_message = "Plan should exit cleanly with no errors."
    }
    assert {
      condition     = run.initial_apply_success.stderr == ""
      error_message = "No errors should be output during plan."
    }
  }
}

Key Components of test Block

variables block

This block allows you to define input variable values specifically for the configuration being tested within this test suite. These variables will override any default values in your main configuration’s variable blocks.

test "my_test_suite" {
  variables {
    instance_count = 2
    environment    = "test-env"
  }
  # ... runs ...
}

Explanation: This is essential for:

  • Unit Testing: Providing specific inputs to a module to test its behavior in isolation.
  • Integration Testing: Setting up different scenarios (e.g., small vs. large deployment).

provider block

You can configure providers within a test block, just like in your main configuration. This is important for controlling how your tests interact with external services.

test "my_aws_test" {
  # ...

  provider "aws" {
    region = "us-west-2"
    # For integration tests, ensure credentials are available in the test environment.
    # For unit tests, you might use a mock provider if available.
  }
  # ... runs ...
}

Provider Mocking (Advanced): While core Terraform does not provide a universal mocking framework, some providers (or community mock providers) offer specific ways to “mock” their behavior. This allows you to run unit tests quickly without needing cloud credentials or incurring costs.

run block

The run block defines an individual test cases or phase within a test suite. Each run block executes a specific Terraform command (plan, apply, destroy), records its output, and evaluates assertions against it.

run "my_apply_scenario" {
  command = apply # Execute 'terraform apply' for this scenario

  # You can pass variables specific to this run block, overriding test.variables
  variables {
    instance_type = "t2.micro"
  }

  # Assertions go here
  assert {
    condition     = aws_instance.web_server.id != null
    error_message = "Web server should be created."
  }
}

Key Arguments of the run block:

  • command: (Required) The Terraform command to execute. Can be plan, apply, or destroy.
  • variables: (Optional) A nested block to provide input variables specific to this run block. These override test.variables.
  • assert: (Optional) A nested block for defining test conditions.
    • condition: A boolean expression that must evaluate to true for the assertion to pass.
    • error_message: The message displayed if the condition is false.

Accessing Run Outputs: Within subsequent run blocks in the same test suite, you can reference the outputs of previous run blocks using run.<run_block_name>.<attribute>.

  • run.my_apply_scenario.stdout: Standard output of the command.
  • run.my_apply_scenario.stderr: Standard error of the command.
  • run.my_apply_scenario.exit_code: The exit code of the command (0 for success).
  • run.my_apply_scenario.<RESOURCE_TYPE>.<RESOURCE_NAME>.<ATTRIBUTE>: Directly access attributes of resources created or planned in the prior run.
  • run.my_apply_scenario.<OUTPUT_NAME>: Access output values defined in the configuration under test.

Types of Tests with test Blocks

Unit Testing (Testing Individual Modules)

Unit tests focus on a single module in isolation, often without deploying real infrastructure. This usually involves:

  • Mocking: Using a mock provider to simulate cloud resources without actual API calls.
  • command = plan: Asserting against the planned changes to verify that the module generates the correct resource attributes.
# modules/my_module/main.tf (module under test)
resource "aws_s3_bucket" "my_bucket" {
  bucket = var.bucket_name
  acl    = var.bucket_acl
}
variable "bucket_name" {}
variable "bucket_acl" {}
output "bucket_arn" { value = aws_s3_bucket.my_bucket.arn }


# modules/my_module/bucket_test.tftest.hcl
test "s3_bucket_unit_test" {
  variables {
    bucket_name = "test-unit-bucket"
    bucket_acl  = "private"
  }

  # This assumes your provider has a mock implementation or you are using a generic mock provider
  provider "aws" {
    # mock = true 
    # Conceptual: how you might enable mocking
    # Refer provider documentation for support and usage
  }

  run "plan_for_public_bucket" {
    command = plan
    variables {
      bucket_acl = "public-read" # Override acl for this specific run
    }
    assert {
      condition     = plan.aws_s3_bucket.my_bucket.acl.value == "public-read"
      error_message = "Planned ACL should be public-read."
    }
    assert {
      condition     = plan.aws_s3_bucket.my_bucket.id.value == "test-unit-bucket"
      error_message = "Planned bucket name should be 'test-unit-bucket'."
    }
  }

  run "plan_for_private_bucket" {
    command = plan # Uses variables from the 'test' block
    assert {
      condition     = plan.aws_s3_bucket.my_bucket.acl.value == "private"
      error_message = "Planned ACL should be private."
    }
  }
}

Integration Testing (Testing Module Interactions or Deployments)

Integration tests deploys ephemeral infrastructure to a real cloud environment (often a dedicated test account). They verify that modules interact correctly and that resources are provisioned as expected.

# root/main.tf (your main configuration that uses modules)
module "network" {
  source = "./modules/network"
  # ...
}

module "app_server" {
  source = "./modules/app_server"
  vpc_id = module.network.vpc_id
  # ...
}


# root/integration_test.tftest.hcl
test "full_deployment_integration" {
  # Ensure you are targeting a test-specific region/account for real deploys
  variables {
    region        = "us-east-1"
    environment   = "integration-test"
    instance_type = "t2.micro"
  }

  provider "aws" {
    region = var.region
    # Ensure this identity has permissions to create/destroy resources in the test account
  }

  run "initial_apply_success" {
    command = apply
    assert {
      condition     = module.network.vpc_id != null
      error_message = "VPC should be created."
    }
    assert {
      condition     = module.app_server.instance_id != null
      error_message = "App server should be created."
    }
    # Can also assert against resource attributes:
    assert {
      condition     = aws_instance.app_server_resource_in_main_config.instance_type == var.instance_type
      error_message = "Instance type mismatch."
    }
  }

  run "re_apply_no_changes" {
    command = apply # Re-apply to check idempotency
    assert {
      condition     = run.initial_apply_success.stdout != null # Just a basic check
      error_message = "Re-apply should output something."
    }
    # A more robust check might be parsing 'stdout' for "No changes" or checking exit code
  }

  run "destroy_cleanup" {
    command = destroy # Clean up resources after tests
    assert {
      condition     = run.re_apply_no_changes.exit_code == 0
      error_message = "Destroy should exit cleanly."
    }
  }
}

Running Terraform Tests

To run your tests, navigate to the directory containing your .tftest.hcl files (or a parent directory) and execute:

terraform test

Useful terraform test flags:

  • terraform test -filter="my_test_suite": Run only specific test suites.
  • terraform test -filter="my_test_suite.initial_apply_success": Run a specific run block within a suite.
  • terraform test -verbose: Show verbose output including stdout/stderr from run commands.
  • terraform test -json: Output results in JSON format (useful for CI/CD).

Best Practices for Terraform Tests

  1. Isolate Test Environments: Always run integration tests in a dedicated, ephemeral test account or isolated sandbox environments to prevent interference with production or development.
  2. Granular Tests: Break down complex test suites into smaller, focused run blocks.
  3. Clean Up: Ensure terraform destroy is part of your integration tests (typically in the last run block) to clean up provisioned resources.
  4. Meaningful Assertions: Write clear condition and error_message pairs that precisely describe what is being tested and what went wrong.
  5. Use outputs for Assertions: Define meaningful output values in your main configuration, as these are often easier to assert against in tests than deeply nested resource attributes.
  6. CI/CD Integration: Automate terraform test execution in your CI/CD pipelines (e.g., on pull requests) to catch regressions early.
  7. Test for “No Changes”: After an initial apply, a subsequent plan or apply that shows “No changes” is a good test of idempotency.
  8. Balance Test Types: Combine unit tests (for speed and isolation) with integration tests (for real-world verification) for comprehensive coverage.
  9. Organize Test Files: Place test files logically, e.g., alongside the module they test (modules/my_module/my_module_test.tftest.hcl).

Conclusion

Terraform testing capability using the test and run blocks is long awaited feature to natively perform unit and integration testing in terraform. It enables you to define and execute comprehensive test cases directly within your HCL, simplifying your testing workflow, reducing the reliance on external tools, and providing immediate feedback on the correctness of your terraform configurations.

Mastering the test block helps you to validate your configurations, have error free deployments, and ensure that your infrastructure consistently meets its desired state. Start incorporating these tests into your terraform development cycle today.

Author

Debjeet Bhowmik

Experienced Cloud & DevOps Engineer with hands-on experience in AWS, GCP, Terraform, Ansible, ELK, Docker, Git, GitLab, Python, PowerShell, Shell, and theoretical knowledge on Azure, Kubernetes & Jenkins.
In my free time, I write blogs on ckdbtech.com

Leave a Comment