Unit Testing Strategies
Unit testing Terraform modules presents unique challenges since infrastructure code ultimately creates real cloud resources. However, you can test much of your Terraform logic—variable validation, conditional expressions, and output calculations—without provisioning actual infrastructure. These techniques catch logic errors early and make your modules more reliable.
This part covers strategies for testing Terraform modules in isolation, validating configuration logic, and ensuring your modules behave correctly across different input scenarios.
Testing Module Logic with Validation
Terraform’s validation blocks provide the first line of defense for unit testing:
# modules/vpc/variables.tf
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "The cidr_block must be a valid CIDR block."
}
validation {
condition = can(regex("^10\\.|^172\\.(1[6-9]|2[0-9]|3[0-1])\\.|^192\\.168\\.", var.cidr_block))
error_message = "The cidr_block must use private IP address space (10.x.x.x, 172.16-31.x.x, or 192.168.x.x)."
}
}
variable "availability_zones" {
description = "List of availability zones"
type = list(string)
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones must be specified for high availability."
}
validation {
condition = length(var.availability_zones) <= 6
error_message = "Maximum of 6 availability zones supported."
}
}
variable "environment" {
description = "Environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "subnet_configuration" {
description = "Subnet configuration"
type = object({
public_subnets = list(string)
private_subnets = list(string)
})
validation {
condition = length(var.subnet_configuration.public_subnets) == length(var.subnet_configuration.private_subnets)
error_message = "Number of public and private subnets must be equal."
}
validation {
condition = alltrue([
for cidr in concat(var.subnet_configuration.public_subnets, var.subnet_configuration.private_subnets) :
can(cidrhost(cidr, 0))
])
error_message = "All subnet CIDR blocks must be valid."
}
}
Testing with Terraform Plan
Use terraform plan
to test module logic without creating resources:
#!/bin/bash
# test-module.sh
set -e
MODULE_DIR="modules/vpc"
TEST_DIR="test/unit"
# Create test directory
mkdir -p "$TEST_DIR"
# Test case 1: Valid configuration
cat > "$TEST_DIR/valid-config.tf" << EOF
module "vpc_test" {
source = "../../$MODULE_DIR"
name = "test-vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-west-2a", "us-west-2b"]
environment = "dev"
subnet_configuration = {
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}
}
output "test_outputs" {
value = {
vpc_id = module.vpc_test.vpc_id
public_subnet_ids = module.vpc_test.public_subnet_ids
private_subnet_ids = module.vpc_test.private_subnet_ids
}
}
EOF
# Test case 2: Invalid CIDR
cat > "$TEST_DIR/invalid-cidr.tf" << EOF
module "vpc_test_invalid" {
source = "../../$MODULE_DIR"
name = "test-vpc"
cidr_block = "invalid-cidr"
availability_zones = ["us-west-2a", "us-west-2b"]
environment = "dev"
subnet_configuration = {
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}
}
EOF
echo "Testing valid configuration..."
cd "$TEST_DIR"
terraform init -backend=false
terraform validate
terraform plan -out=valid.tfplan
echo "Testing invalid configuration..."
if terraform validate -var-file=<(echo 'cidr_block = "invalid-cidr"') 2>/dev/null; then
echo "ERROR: Invalid configuration should have failed validation"
exit 1
else
echo "SUCCESS: Invalid configuration correctly rejected"
fi
echo "All unit tests passed!"
Testing Local Values and Expressions
Test complex local value calculations:
# modules/networking/locals-test.tf
locals {
# Test subnet CIDR calculation
test_vpc_cidr = "10.0.0.0/16"
test_az_count = 3
# Calculate subnet CIDRs
public_subnet_cidrs = [
for i in range(local.test_az_count) :
cidrsubnet(local.test_vpc_cidr, 8, i + 1)
]
private_subnet_cidrs = [
for i in range(local.test_az_count) :
cidrsubnet(local.test_vpc_cidr, 8, i + 11)
]
# Test naming conventions
resource_names = {
for i in range(local.test_az_count) :
"subnet-${i}" => {
public = "public-subnet-${i + 1}"
private = "private-subnet-${i + 1}"
}
}
# Test conditional logic
environment_config = {
dev = {
instance_type = "t3.micro"
min_size = 1
max_size = 3
}
prod = {
instance_type = "t3.large"
min_size = 3
max_size = 10
}
}
selected_config = local.environment_config[var.environment]
}
# Output calculated values for testing
output "calculated_subnets" {
value = {
public_cidrs = local.public_subnet_cidrs
private_cidrs = local.private_subnet_cidrs
}
}
output "resource_names" {
value = local.resource_names
}
output "environment_config" {
value = local.selected_config
}
Mock Testing with Null Resources
Use null resources to test logic without creating real infrastructure:
# test/unit/mock-test.tf
variable "test_scenarios" {
description = "Test scenarios for validation"
type = map(object({
environment = string
region = string
az_count = number
}))
default = {
scenario_1 = {
environment = "dev"
region = "us-west-2"
az_count = 2
}
scenario_2 = {
environment = "prod"
region = "us-east-1"
az_count = 3
}
}
}
# Mock data sources
locals {
mock_availability_zones = {
"us-west-2" = ["us-west-2a", "us-west-2b", "us-west-2c"]
"us-east-1" = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
# Test module logic with null resources
resource "null_resource" "test_scenarios" {
for_each = var.test_scenarios
triggers = {
environment = each.value.environment
region = each.value.region
az_count = each.value.az_count
# Test subnet calculation
vpc_cidr = "10.0.0.0/16"
public_subnets = jsonencode([
for i in range(each.value.az_count) :
cidrsubnet("10.0.0.0/16", 8, i + 1)
])
private_subnets = jsonencode([
for i in range(each.value.az_count) :
cidrsubnet("10.0.0.0/16", 8, i + 11)
])
# Test naming
resource_prefix = "${each.value.environment}-${each.value.region}"
# Test availability zones
selected_azs = jsonencode(slice(
local.mock_availability_zones[each.value.region],
0,
each.value.az_count
))
}
}
output "test_results" {
value = {
for scenario, resource in null_resource.test_scenarios :
scenario => {
environment = resource.triggers.environment
region = resource.triggers.region
vpc_cidr = resource.triggers.vpc_cidr
public_subnets = jsondecode(resource.triggers.public_subnets)
private_subnets = jsondecode(resource.triggers.private_subnets)
resource_prefix = resource.triggers.resource_prefix
selected_azs = jsondecode(resource.triggers.selected_azs)
}
}
}
Testing with Terraform Console
Use Terraform console for interactive testing:
# test-console.sh
#!/bin/bash
# Start terraform console with test variables
terraform console << 'EOF'
# Test CIDR calculations
cidrsubnet("10.0.0.0/16", 8, 1)
cidrsubnet("10.0.0.0/16", 8, 11)
# Test list operations
[for i in range(3) : "subnet-${i + 1}"]
# Test conditional expressions
"dev" == "prod" ? "t3.large" : "t3.micro"
# Test validation functions
can(cidrhost("10.0.0.0/16", 0))
can(cidrhost("invalid-cidr", 0))
# Test string operations
replace("my-resource-name", "-", "_")
upper("environment")
lower("PRODUCTION")
# Test map operations
merge({"a" = 1}, {"b" = 2})
# Test complex expressions
{
for env in ["dev", "staging", "prod"] :
env => {
instance_type = env == "prod" ? "t3.large" : "t3.micro"
min_size = env == "prod" ? 3 : 1
}
}
EOF
Automated Unit Test Suite
Create an automated test suite for your modules:
#!/usr/bin/env python3
# test_terraform_modules.py
import subprocess
import json
import os
import tempfile
import shutil
from pathlib import Path
class TerraformModuleTester:
def __init__(self, module_path):
self.module_path = Path(module_path)
self.test_dir = None
def setup_test_environment(self):
"""Create temporary test directory"""
self.test_dir = Path(tempfile.mkdtemp())
return self.test_dir
def cleanup_test_environment(self):
"""Clean up temporary test directory"""
if self.test_dir and self.test_dir.exists():
shutil.rmtree(self.test_dir)
def create_test_config(self, config_content):
"""Create test configuration file"""
config_file = self.test_dir / "test.tf"
config_file.write_text(config_content)
return config_file
def run_terraform_command(self, command, cwd=None):
"""Run terraform command and return result"""
if cwd is None:
cwd = self.test_dir
try:
result = subprocess.run(
["terraform"] + command,
cwd=cwd,
capture_output=True,
text=True,
check=True
)
return {"success": True, "stdout": result.stdout, "stderr": result.stderr}
except subprocess.CalledProcessError as e:
return {"success": False, "stdout": e.stdout, "stderr": e.stderr}
def test_valid_configuration(self, config):
"""Test that valid configuration passes validation"""
self.setup_test_environment()
try:
self.create_test_config(config)
# Initialize
init_result = self.run_terraform_command(["init", "-backend=false"])
if not init_result["success"]:
return False, f"Init failed: {init_result['stderr']}"
# Validate
validate_result = self.run_terraform_command(["validate"])
if not validate_result["success"]:
return False, f"Validation failed: {validate_result['stderr']}"
# Plan
plan_result = self.run_terraform_command(["plan", "-out=test.tfplan"])
if not plan_result["success"]:
return False, f"Plan failed: {plan_result['stderr']}"
return True, "Configuration is valid"
finally:
self.cleanup_test_environment()
def test_invalid_configuration(self, config, expected_error=None):
"""Test that invalid configuration fails validation"""
self.setup_test_environment()
try:
self.create_test_config(config)
# Initialize
init_result = self.run_terraform_command(["init", "-backend=false"])
if not init_result["success"]:
return True, f"Init correctly failed: {init_result['stderr']}"
# Validate
validate_result = self.run_terraform_command(["validate"])
if not validate_result["success"]:
if expected_error and expected_error in validate_result["stderr"]:
return True, f"Validation correctly failed with expected error"
return True, f"Validation correctly failed: {validate_result['stderr']}"
return False, "Configuration should have failed validation"
finally:
self.cleanup_test_environment()
def test_output_values(self, config, expected_outputs):
"""Test that outputs match expected values"""
self.setup_test_environment()
try:
self.create_test_config(config)
# Initialize and plan
self.run_terraform_command(["init", "-backend=false"])
plan_result = self.run_terraform_command(["plan", "-out=test.tfplan"])
if not plan_result["success"]:
return False, f"Plan failed: {plan_result['stderr']}"
# Get planned outputs
show_result = self.run_terraform_command(["show", "-json", "test.tfplan"])
if not show_result["success"]:
return False, f"Show failed: {show_result['stderr']}"
plan_data = json.loads(show_result["stdout"])
planned_outputs = plan_data.get("planned_values", {}).get("outputs", {})
# Compare outputs
for output_name, expected_value in expected_outputs.items():
if output_name not in planned_outputs:
return False, f"Output '{output_name}' not found"
actual_value = planned_outputs[output_name]["value"]
if actual_value != expected_value:
return False, f"Output '{output_name}': expected {expected_value}, got {actual_value}"
return True, "All outputs match expected values"
finally:
self.cleanup_test_environment()
# Test cases
def test_vpc_module():
tester = TerraformModuleTester("modules/vpc")
# Test valid configuration
valid_config = '''
module "vpc_test" {
source = "../../modules/vpc"
name = "test-vpc"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-west-2a", "us-west-2b"]
environment = "dev"
}
output "vpc_cidr" {
value = module.vpc_test.vpc_cidr_block
}
'''
success, message = tester.test_valid_configuration(valid_config)
print(f"Valid configuration test: {'PASS' if success else 'FAIL'} - {message}")
# Test invalid CIDR
invalid_config = '''
module "vpc_test" {
source = "../../modules/vpc"
name = "test-vpc"
cidr_block = "invalid-cidr"
availability_zones = ["us-west-2a", "us-west-2b"]
environment = "dev"
}
'''
success, message = tester.test_invalid_configuration(invalid_config, "valid CIDR block")
print(f"Invalid CIDR test: {'PASS' if success else 'FAIL'} - {message}")
if __name__ == "__main__":
test_vpc_module()
Property-Based Testing
Use property-based testing for comprehensive validation:
#!/usr/bin/env python3
# property_based_tests.py
import hypothesis
from hypothesis import given, strategies as st
import ipaddress
import subprocess
import tempfile
import json
# Property-based test for CIDR calculations
@given(
vpc_cidr=st.from_regex(r"10\.\d{1,3}\.\d{1,3}\.0/16"),
subnet_count=st.integers(min_value=1, max_value=10)
)
def test_subnet_cidr_calculation(vpc_cidr, subnet_count):
"""Test that subnet CIDR calculations are valid"""
# Validate VPC CIDR
try:
vpc_network = ipaddress.IPv4Network(vpc_cidr)
except ValueError:
return # Skip invalid CIDR
# Calculate subnet CIDRs (simulating Terraform logic)
subnet_cidrs = []
for i in range(subnet_count):
try:
subnet = list(vpc_network.subnets(new_prefix=24))[i]
subnet_cidrs.append(str(subnet))
except IndexError:
break # Not enough subnets available
# Verify all subnets are within VPC CIDR
for subnet_cidr in subnet_cidrs:
subnet_network = ipaddress.IPv4Network(subnet_cidr)
assert subnet_network.subnet_of(vpc_network), f"Subnet {subnet_cidr} not within VPC {vpc_cidr}"
# Verify no subnet overlap
for i, subnet1 in enumerate(subnet_cidrs):
for subnet2 in subnet_cidrs[i+1:]:
net1 = ipaddress.IPv4Network(subnet1)
net2 = ipaddress.IPv4Network(subnet2)
assert not net1.overlaps(net2), f"Subnets {subnet1} and {subnet2} overlap"
# Property-based test for resource naming
@given(
environment=st.sampled_from(["dev", "staging", "prod"]),
region=st.sampled_from(["us-west-2", "us-east-1", "eu-west-1"]),
resource_type=st.sampled_from(["vpc", "subnet", "sg", "instance"])
)
def test_resource_naming_convention(environment, region, resource_type):
"""Test that resource names follow conventions"""
# Simulate Terraform naming logic
resource_name = f"{environment}-{region}-{resource_type}"
# Verify naming conventions
assert len(resource_name) <= 63, "Resource name too long"
assert resource_name.replace("-", "").replace("_", "").isalnum(), "Resource name contains invalid characters"
assert not resource_name.startswith("-"), "Resource name cannot start with hyphen"
assert not resource_name.endswith("-"), "Resource name cannot end with hyphen"
if __name__ == "__main__":
# Run property-based tests
test_subnet_cidr_calculation()
test_resource_naming_convention()
print("All property-based tests passed!")
What’s Next
Unit testing strategies help you catch logic errors and validate module behavior without provisioning real infrastructure. However, some issues only surface when your modules interact with actual cloud services and real network conditions.
In the next part, we’ll explore integration testing with Terratest and other tools that provision real cloud resources to validate that your infrastructure works correctly in practice.