Skip to content

Unit Test IaC with Terraform Tests

Testing Terraform

A challenge of being a DevOps engineer is that some of the typical dev parts don't always fit nicely. Unit testing is one example, where IaC is often hard to test in isolation and without some sort of actual deployment in a "test" or "sandbox" environment to validate against.

The lines between unit and integration testing become blurred for many DevOps or Platform engineers. Thankfully, HashiCorp has seen this gap and has introduced Terraform Tests to help bridge this gap!

In this post I will go over practical unit testing strategies using terraform test to validate your Infrastructure as Code, improve code quality, and provide confidence when making changes to your IaC modules.

The IaC Testing Challenge

Traditional software development has well-established testing patterns - unit tests, integration tests, end-to-end tests. But Infrastructure as Code presents unique challenges:

Why IaC Testing is Different

No Local Execution: Infrastructure requires actual cloud resources ❌ State Dependencies: Resources often depend on external state ❌ Cost Concerns: Every test run could create billable resources ❌ Time Constraints: Infrastructure deployments take minutes, not milliseconds ❌ Complex Dependencies: Resources have intricate relationships

Enter Terraform Tests

Terraform Tests solve many of these issues by providing:

Fast Validation: Test logic without deploying resources ✅ Cost-Free Testing: Validate configurations without creating infrastructure ✅ Early Feedback: Catch issues before expensive deployment cycles ✅ CI/CD Integration: Automate validation in your pipelines ✅ Modular Testing: Test individual components in isolation

Understanding Terraform Test Types

Terraform supports multiple testing approaches:

1. Plan-Only Tests

Validate resource configuration without deployment:

run "validate_s3_bucket_config" {
 command = plan

 assert {
 condition = aws_s3_bucket.example.bucket == "my-test-bucket"
 error_message = "S3 bucket name must be 'my-test-bucket'"
 }
}

2. Apply Tests

Create actual resources for integration testing:

run "deploy_and_validate" {
 command = apply

 assert {
 condition = aws_s3_bucket.example.arn!= ""
 error_message = "S3 bucket should have a valid ARN after creation"
 }
}

3. Mock Provider Tests

Test with mock data for fast feedback:

run "mock_provider_test" {
 command = plan

 providers = {
 aws = aws.mock
 }

 # Test assertions here
}

Setting Up Terraform Tests

Project Structure

terraform-module/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── tests/
 ├── basic_functionality.tftest.hcl
 ├── edge_cases.tftest.hcl
 ├── security_validation.tftest.hcl
 └── integration.tftest.hcl

Basic Test File Structure

# tests/basic_functionality.tftest.hcl

# Test configuration
terraform {
 required_providers {
 aws = {
 source = "hashicorp/aws"
 version = "~> 5.0"
 }
 }
}

# Variables for testing
variables {
 bucket_name = "test-bucket-${random_string.test_suffix.result}"
 environment = "test"
}

# Generate unique suffix for test resources
resource "random_string" "test_suffix" {
 length = 8
 special = false
 upper = false
}

# Test runs
run "validate_bucket_naming" {
 command = plan

 assert {
 condition = can(regex("^test-bucket-[a-z0-9]{8}$", aws_s3_bucket.example.bucket))
 error_message = "Bucket name must follow the pattern 'test-bucket-{8-char-suffix}'"
 }
}

run "validate_bucket_encryption" {
 command = plan

 assert {
 condition = aws_s3_bucket_server_side_encryption_configuration.example.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "AES256"
 error_message = "S3 bucket must use AES256 encryption"
 }
}

Real-World Example: S3 Bucket Module

Let's create a comprehensive test suite for an S3 bucket module:

The Module (main.tf)

# main.tf
resource "aws_s3_bucket" "this" {
 bucket = var.bucket_name

 tags = merge(var.tags, {
 Name = var.bucket_name
 Environment = var.environment
 ManagedBy = "terraform"
 })
}

resource "aws_s3_bucket_versioning" "this" {
 bucket = aws_s3_bucket.this.id
 versioning_configuration {
 status = var.versioning_enabled? "Enabled": "Disabled"
 }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
 bucket = aws_s3_bucket.this.id

 rule {
 apply_server_side_encryption_by_default {
 kms_master_key_id = var.kms_key_id
 sse_algorithm = var.kms_key_id!= null? "aws:kms": "AES256"
 }
 bucket_key_enabled = var.kms_key_id!= null
 }
}

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
}

Variables (variables.tf)

# variables.tf
variable "bucket_name" {
 description = "Name of the S3 bucket"
 type = string

 validation {
 condition = can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", var.bucket_name))
 error_message = "Bucket name must be lowercase alphanumeric with hyphens."
 }
}

variable "environment" {
 description = "Environment name"
 type = string
 default = "dev"

 validation {
 condition = contains(["dev", "staging", "prod"], var.environment)
 error_message = "Environment must be dev, staging, or prod."
 }
}

variable "versioning_enabled" {
 description = "Enable versioning on the S3 bucket"
 type = bool
 default = true
}

variable "kms_key_id" {
 description = "KMS key ID for encryption"
 type = string
 default = null
}

variable "tags" {
 description = "Additional tags for the bucket"
 type = map(string)
 default = {}
}

Comprehensive Test Suite

# tests/s3_bucket_comprehensive.tftest.hcl

terraform {
 required_providers {
 aws = {
 source = "hashicorp/aws"
 version = "~> 5.0"
 }
 random = {
 source = "hashicorp/random"
 version = "~> 3.1"
 }
 }
}

# Generate unique test identifier
resource "random_id" "test_suffix" {
 byte_length = 4
}

locals {
 test_bucket_name = "terraform-test-bucket-${random_id.test_suffix.hex}"
}

# Test 1: Basic bucket configuration validation
run "validate_basic_config" {
 command = plan

 variables {
 bucket_name = local.test_bucket_name
 environment = "test"
 }

 # Test bucket name is correctly set
 assert {
 condition = aws_s3_bucket.this.bucket == local.test_bucket_name
 error_message = "Bucket name should match the input variable"
 }

 # Test default versioning is enabled
 assert {
 condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
 error_message = "Versioning should be enabled by default"
 }

 # Test public access is blocked
 assert {
 condition = (
 aws_s3_bucket_public_access_block.this.block_public_acls == true &&
 aws_s3_bucket_public_access_block.this.block_public_policy == true &&
 aws_s3_bucket_public_access_block.this.ignore_public_acls == true &&
 aws_s3_bucket_public_access_block.this.restrict_public_buckets == true
 )
 error_message = "All public access should be blocked for security"
 }
}

# Test 2: Custom configuration options
run "validate_custom_config" {
 command = plan

 variables {
 bucket_name = local.test_bucket_name
 environment = "prod"
 versioning_enabled = false
 tags = {
 Team = "platform"
 Project = "testing"
 }
 }

 # Test versioning can be disabled
 assert {
 condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Disabled"
 error_message = "Versioning should be disabled when versioning_enabled = false"
 }

 # Test custom tags are applied
 assert {
 condition = aws_s3_bucket.this.tags["Team"] == "platform"
 error_message = "Custom tags should be applied to the bucket"
 }

 # Test environment tag is set
 assert {
 condition = aws_s3_bucket.this.tags["Environment"] == "prod"
 error_message = "Environment tag should be set correctly"
 }

 # Test managed by tag is added
 assert {
 condition = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
 error_message = "ManagedBy tag should be automatically added"
 }
}

# Test 3: KMS encryption configuration
run "validate_kms_encryption" {
 command = plan

 variables {
 bucket_name = local.test_bucket_name
 kms_key_id = "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
 }

 # Test KMS encryption is configured
 assert {
 condition = (
 aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "aws:kms" &&
 aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].kms_master_key_id!= null
 )
 error_message = "KMS encryption should be configured when kms_key_id is provided"
 }

 # Test bucket key is enabled for KMS
 assert {
 condition = aws_s3_bucket_server_side_encryption_configuration.this.rule[0].bucket_key_enabled == true
 error_message = "Bucket key should be enabled for KMS encryption to reduce costs"
 }
}

# Test 4: Default AES256 encryption
run "validate_default_encryption" {
 command = plan

 variables {
 bucket_name = local.test_bucket_name
 # kms_key_id not provided, should default to AES256
 }

 # Test default encryption is AES256
 assert {
 condition = (
 aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "AES256" &&
 aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].kms_master_key_id == null
 )
 error_message = "Should use AES256 encryption when no KMS key is provided"
 }
}

Input Validation Tests

# tests/input_validation.tftest.hcl

# Test invalid bucket names
run "invalid_bucket_name_uppercase" {
 command = plan

 variables {
 bucket_name = "INVALID-BUCKET-NAME"
 }

 expect_failures = [
 var.bucket_name,
 ]
}

run "invalid_bucket_name_underscore" {
 command = plan

 variables {
 bucket_name = "invalid_bucket_name"
 }

 expect_failures = [
 var.bucket_name,
 ]
}

# Test invalid environment
run "invalid_environment" {
 command = plan

 variables {
 bucket_name = "valid-bucket-name"
 environment = "invalid-env"
 }

 expect_failures = [
 var.environment,
 ]
}

Advanced Testing Patterns

Mock Providers for Fast Testing

# tests/mock_provider_tests.tftest.hcl

# Configure mock provider
provider "aws" {
 alias = "mock"

 # Mock configuration - no real AWS calls
 access_key = "mock_access_key"
 secret_key = "mock_secret_key"
 region = "us-west-2"
 skip_credentials_validation = true
 skip_metadata_api_check = true
 skip_region_validation = true

 endpoints {
 s3 = "http://localhost:4566" # LocalStack endpoint
 }
}

run "mock_s3_test" {
 command = plan

 providers = {
 aws = aws.mock
 }

 variables {
 bucket_name = "mock-test-bucket"
 }

 # Test configuration without real AWS resources
 assert {
 condition = aws_s3_bucket.this.bucket == "mock-test-bucket"
 error_message = "Mock bucket name validation failed"
 }
}

Testing with Different Provider Configurations

# tests/multi_provider.tftest.hcl

provider "aws" {
 alias = "us_east_1"
 region = "us-east-1"
}

provider "aws" {
 alias = "us_west_2"
 region = "us-west-2"
}

run "test_us_east_1_deployment" {
 command = plan

 providers = {
 aws = aws.us_east_1
 }

 variables {
 bucket_name = "test-bucket-us-east-1"
 }

 # Region-specific assertions
 assert {
 condition = aws_s3_bucket.this.bucket == "test-bucket-us-east-1"
 error_message = "US East 1 bucket should be configured correctly"
 }
}

Data-Driven Testing

# tests/data_driven_tests.tftest.hcl

locals {
 test_scenarios = [
 {
 name = "development"
 environment = "dev"
 versioning = false
 tags = {
 CostCenter = "development"
 }
 },
 {
 name = "production"
 environment = "prod"
 versioning = true
 tags = {
 CostCenter = "production"
 Compliance = "required"
 }
 }
 ]
}

# Generate tests for each scenario
dynamic "run" {
 for_each = local.test_scenarios
 content {
 command = plan

 variables {
 bucket_name = "test-${run.value.name}"
 environment = run.value.environment
 versioning_enabled = run.value.versioning
 tags = run.value.tags
 }

 assert {
 condition = aws_s3_bucket.this.tags["Environment"] == run.value.environment
 error_message = "Environment tag should match scenario: ${run.value.name}"
 }
 }
}

Integration with CI/CD

GitLab CI Integration

#.gitlab-ci.yml
stages:
 - validate
 - test
 - plan
 - apply

variables:
 TF_ROOT: ${CI_PROJECT_DIR}
 TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/production

terraform-validate:
 stage: validate
 image:
 name: hashicorp/terraform:latest
 entrypoint: ['']
 script:
 - cd ${TF_ROOT}
 - terraform init -backend=false
 - terraform fmt -check
 - terraform validate

terraform-test:
 stage: test
 image:
 name: hashicorp/terraform:latest
 entrypoint: ['']
 before_script:
 - cd ${TF_ROOT}
 - terraform init -backend=false
 script:
 - terraform test
 artifacts:
 reports:
 junit: test-results.xml
 only:
 - merge_requests
 - main

terraform-plan:
 stage: plan
 image:
 name: hashicorp/terraform:latest
 entrypoint: ['']
 script:
 - cd ${TF_ROOT}
 - terraform init
 - terraform plan -out=tfplan
 artifacts:
 paths:
 - ${TF_ROOT}/tfplan
 only:
 - main

GitHub Actions Integration

#.github/workflows/terraform.yml
name: Terraform Tests

on:
 push:
 branches: [main]
 pull_request:
 branches: [main]

jobs:
 terraform-tests:
 runs-on: ubuntu-latest

 steps:
 - uses: actions/checkout@v4

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v3
 with:
 terraform_version: "1.6.0"

 - name: Terraform Init
 run: terraform init -backend=false

 - name: Terraform Format Check
 run: terraform fmt -check

 - name: Terraform Validate
 run: terraform validate

 - name: Terraform Test
 run: terraform test

 - name: Upload Test Results
 uses: actions/upload-artifact@v4
 if: always()
 with:
 name: terraform-test-results
 path: |
 test-results.json
 test-*.log

Testing Best Practices

1. Test Organization

tests/
├── unit/ # Fast, isolated tests
│ ├── resource_config.tftest.hcl
│ └── input_validation.tftest.hcl
├── integration/ # Tests that deploy resources
│ ├── end_to_end.tftest.hcl
│ └── cross_module.tftest.hcl
└── performance/ # Large-scale testing
 └── scale_tests.tftest.hcl

2. Naming Conventions

# Good: Descriptive test names
run "s3_bucket_has_encryption_enabled" {... }
run "vpc_subnets_are_distributed_across_azs" {... }

# Bad: Generic test names
run "test1" {... }
run "basic_test" {... }

3. Clear Assertions

# Good: Specific error messages
assert {
 condition = length(aws_subnet.private) >= 2
 error_message = "Must create at least 2 private subnets for high availability"
}

# Bad: Generic error messages
assert {
 condition = length(aws_subnet.private) >= 2
 error_message = "Assertion failed"
}

4. Test Independence

# Good: Each test is independent
run "test_with_versioning_enabled" {
 variables {
 versioning_enabled = true
 }
 # Test logic
}

run "test_with_versioning_disabled" {
 variables {
 versioning_enabled = false
 }
 # Test logic
}

Running Terraform Tests

Basic Commands

# Run all tests
terraform test

# Run specific test file
terraform test tests/basic_functionality.tftest.hcl

# Run tests with verbose output
terraform test -verbose

# Run tests in parallel
terraform test -parallel=4

# Run tests with JSON output
terraform test -json

Test Filtering

# Run only plan tests
terraform test -filter="command:plan"

# Run specific test by name
terraform test -filter="run.validate_bucket_naming"

# Run tests matching pattern
terraform test -filter="name:*encryption*"

Troubleshooting Common Issues

Test Execution Errors

# Debug test execution
TF_LOG=DEBUG terraform test

# Check test file syntax
terraform fmt tests/

# Validate test configuration
terraform -chdir=tests validate

Resource Cleanup

# Always include cleanup for apply tests
run "integration_test_with_cleanup" {
 command = apply

 variables {
 bucket_name = "test-bucket-${random_id.test.hex}"
 }

 assert {
 condition = aws_s3_bucket.this.arn!= ""
 error_message = "Bucket should be created successfully"
 }

 # Cleanup happens automatically after test
}

Real-World Testing Scenarios

Multi-Module Testing

# tests/multi_module_integration.tftest.hcl

# Test module composition
module "vpc" {
 source = "./modules/vpc"
 #... configuration
}

module "s3" {
 source = "./modules/s3"
 vpc_id = module.vpc.vpc_id
 #... configuration
}

run "modules_integrate_correctly" {
 command = plan

 assert {
 condition = module.s3.bucket_vpc_endpoint!= ""
 error_message = "S3 module should create VPC endpoint when VPC is provided"
 }
}

Security Compliance Testing

# tests/security_compliance.tftest.hcl

run "security_standards_compliance" {
 command = plan

 # Test encryption at rest
 assert {
 condition = (
 aws_s3_bucket_server_side_encryption_configuration.this.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm!= ""
 )
 error_message = "All S3 buckets must have encryption at rest enabled"
 }

 # Test public access is blocked
 assert {
 condition = (
 aws_s3_bucket_public_access_block.this.block_public_acls &&
 aws_s3_bucket_public_access_block.this.block_public_policy
 )
 error_message = "S3 buckets must block all public access for security compliance"
 }

 # Test versioning for production
 assert {
 condition = var.environment == "prod"? aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled": true
 error_message = "Production S3 buckets must have versioning enabled"
 }
}

Conclusion

Terraform Tests revolutionize Infrastructure as Code validation by bringing software development best practices to infrastructure management. With proper testing strategies, you can:

Catch Configuration Errors Early - Before expensive deployments ✅ Improve Code Quality - Through systematic validation ✅ Enable Safe Refactoring - With confidence in changes ✅ Accelerate Development - Through fast feedback loops ✅ Ensure Compliance - By codifying requirements as tests

Key Takeaways

  1. Start Simple: Begin with basic plan-only tests
  2. Test Early: Integrate testing into your development workflow
  3. Be Comprehensive: Cover edge cases and error conditions
  4. Automate Everything: Run tests in CI/CD pipelines
  5. Iterate Continuously: Improve tests based on real-world failures

Next Steps

  1. Add Tests to Existing Modules: Start with your most critical infrastructure
  2. Integrate with CI/CD: Automate testing in your pipelines
  3. Create Test Libraries: Build reusable test patterns for your team
  4. Monitor and Improve: Track test effectiveness and expand coverage

With Terraform Tests, you can finally bring the reliability and confidence of unit testing to your Infrastructure as Code. No more crossing your fingers during deployments!


Pro Tip

Combine Terraform Tests with the BATS testing from our BATS Kubernetes Testing guide for comprehensive infrastructure and application validation!

Ready to start testing your IaC? Begin with a simple module and gradually expand your test coverage. Your future self will thank you! 🧪