Unit Test IaC with Terraform Tests

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:
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¶
- Start Simple: Begin with basic plan-only tests
- Test Early: Integrate testing into your development workflow
- Be Comprehensive: Cover edge cases and error conditions
- Automate Everything: Run tests in CI/CD pipelines
- Iterate Continuously: Improve tests based on real-world failures
Next Steps¶
- Add Tests to Existing Modules: Start with your most critical infrastructure
- Integrate with CI/CD: Automate testing in your pipelines
- Create Test Libraries: Build reusable test patterns for your team
- 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! 🧪