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.