Pruebas de Infraestructura con Terratest

Terratest is a Go library for testing infrastructure code by actually provisioning infrastructure and validating it works correctly. Using Terratest, you write automated tests for Terraform, CloudFormation, Kubernetes, and Docker, catching infrastructure bugs before production deployment. This guide covers Go setup, writing infrastructure tests, using Terraform helpers, HTTP testing, retry logic, cleanup patterns, and CI/CD integration.

Tabla de Contenidos

  1. Terratest Overview
  2. Go Environment Setup
  3. Basic Terratest Structure
  4. Terraform Testing
  5. HTTP and Network Testing
  6. SSH Testing
  7. Retry and Polling
  8. Cleanup and Resource Management
  9. CI/CD Integration
  10. Conclusion

Descripción General de Terratest

Terratest enables testing infrastructure code by provisioning actual resources in test environments. Unlike unit tests, infrastructure tests validate that code works in real cloud environments, catching configuration mistakes and integration issues early.

Key benefits:

  • Real Testing: Test actual infrastructure, not mocked resources
  • Automation: Automated validation reduces manual testing
  • Early Detection: Catch bugs before production
  • Documentation: Tests document how infrastructure should work
  • Regression Prevention: Catch breaking changes
  • Confidence: Know your infrastructure code works

Terratest workflow:

Test Code
   │
   ├── Provision Infrastructure (Terraform/CloudFormation)
   │   │
   │   └── Create real resources in test environment
   │
   ├── Test Infrastructure
   │   │
   │   ├── HTTP requests
   │   ├── SSH connections
   │   ├── Resource validation
   │   └── Integration testing
   │
   ├── Validate Results
   │   │
   │   └── Assert expected behavior
   │
   └── Cleanup
       │
       └── Destroy test resources

Configuración del Entorno Go

Install Go and configure Terratest.

Install Go:

# macOS
brew install go

# Ubuntu/Debian
wget https://go.dev/dl/go1.20.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.20.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

# Windows
choco install golang

# Verify
go version

Initialize Go module:

# Create test directory
mkdir terraform-tests
cd terraform-tests

# Initialize go module
go mod init terraform-tests

# Add Terratest dependency
go get -u github.com/gruntwork-io/terratest
go get -u github.com/gruntwork-io/terratest/modules/terraform
go get -u github.com/gruntwork-io/terratest/modules/http-helper

Project structure:

terraform-tests/
├── go.mod
├── go.sum
├── test/
│   ├── terraform_test.go
│   └── integration_test.go
├── terraform/
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
└── Makefile

Estructura Básica de Terratest

Create and run basic infrastructure tests.

Simple test file:

// test/terraform_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestTerraformExample(t *testing.T) {
	// Terraform options
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
		Vars: map[string]interface{}{
			"environment": "test",
			"region":      "us-east-1",
		},
	}

	// Cleanup after test
	defer terraform.Destroy(t, terraformOptions)

	// Apply infrastructure
	terraform.InitAndApply(t, terraformOptions)

	// Get outputs
	vpcId := terraform.Output(t, terraformOptions, "vpc_id")
	subnetId := terraform.Output(t, terraformOptions, "subnet_id")

	// Validate outputs exist
	assert.NotEmpty(t, vpcId, "VPC ID should not be empty")
	assert.NotEmpty(t, subnetId, "Subnet ID should not be empty")
}

Run tests:

# Run specific test
go test -v test/terraform_test.go -timeout 30m

# Run all tests
go test -v ./test/... -timeout 30m

# Run with parallel execution
go test -v -parallel 2 ./test/... -timeout 30m

# Run specific test function
go test -v -run TestTerraformExample ./test/...

Makefile for testing:

# Makefile
.PHONY: test test-verbose test-short clean

test:
	go test -v -timeout 30m ./test/...

test-verbose:
	go test -v -timeout 30m ./test/... -count=1

test-short:
	go test -short -timeout 5m ./test/...

clean:
	find terraform -name '.terraform' -type d -exec rm -rf {} + 2>/dev/null || true
	find terraform -name 'terraform.tfstate*' -delete
	find terraform -name '.terraform.lock.hcl' -delete

test-clean: clean test

Pruebas de Terraform

Test Terraform configurations comprehensively.

Configuration testing:

// test/terraform_config_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestTerraformPlan(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	// Just run plan, don't apply
	plan := terraform.InitAndPlan(t, terraformOptions)
	
	// Verify plan output
	assert.NotEmpty(t, plan)
	assert.Contains(t, plan, "Plan:")
}

func TestTerraformValidate(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	// Validate syntax and configuration
	terraform.Init(t, terraformOptions)
	err := terraform.Validate(t, terraformOptions)
	
	require.NoError(t, err, "Terraform validation should succeed")
}

func TestTerraformApplyIdempotent(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
		Vars: map[string]interface{}{
			"environment": "test",
		},
	}

	defer terraform.Destroy(t, terraformOptions)

	// First apply
	terraform.InitAndApply(t, terraformOptions)

	// Second apply should detect no changes
	secondApply := terraform.Plan(t, terraformOptions)
	assert.Contains(t, secondApply, "No changes")
}

func TestTerraformVariables(t *testing.T) {
	tests := map[string]struct {
		vars  map[string]interface{}
		error bool
	}{
		"valid": {
			vars: map[string]interface{}{
				"instance_type": "t2.micro",
				"environment":   "test",
			},
			error: false,
		},
		"invalid_instance_type": {
			vars: map[string]interface{}{
				"instance_type": "invalid-type",
			},
			error: true,
		},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			terraformOptions := &terraform.Options{
				TerraformDir: "../terraform",
				Vars:         tc.vars,
			}

			_, err := terraform.InitAndApplyE(t, terraformOptions)
			
			if tc.error {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
				terraform.Destroy(t, terraformOptions)
			}
		})
	}
}

Resource validation:

// test/terraform_aws_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/aws"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestAWSResources(t *testing.T) {
	awsRegion := "us-east-1"

	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
		Vars: map[string]interface{}{
			"aws_region": awsRegion,
		},
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Get VPC ID from Terraform output
	vpcId := terraform.Output(t, terraformOptions, "vpc_id")
	
	// Validate VPC exists
	vpc := aws.GetVpcById(t, vpcId, awsRegion)
	assert.NotNil(t, vpc)
	assert.Equal(t, "10.0.0.0/16", *vpc.CidrBlock)

	// Check security group
	sgId := terraform.Output(t, terraformOptions, "security_group_id")
	sg := aws.GetSecurityGroupById(t, sgId, awsRegion)
	assert.NotNil(t, sg)
	assert.Equal(t, 1, len(sg.IpPermissions))

	// Verify instance
	instanceId := terraform.Output(t, terraformOptions, "instance_id")
	reservation := aws.GetEC2InstanceById(t, instanceId, awsRegion)
	assert.NotNil(t, reservation)
	assert.Equal(t, "running", *reservation.Instances[0].State.Name)
}

Pruebas HTTP y de Red

Test infrastructure accessibility via HTTP and network protocols.

HTTP endpoint testing:

// test/http_test.go
package test

import (
	"fmt"
	"testing"
	"time"
	"github.com/gruntwork-io/terratest/modules/http-helper"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestHTTPEndpoint(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Get server URL
	url := terraform.Output(t, terraformOptions, "server_url")

	// Test HTTP endpoint
	expectedStatus := 200
	expectedBody := "Hello World"

	http_helper.HttpGetWithValidationE(
		t,
		url,
		nil,
		expectedStatus,
		expectedBody,
	)
}

func TestHTTPWithRetry(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	url := terraform.Output(t, terraformOptions, "server_url")

	// Retry logic for eventual consistency
	maxRetries := 30
	timeBetweenRetries := time.Second

	http_helper.HttpGetWithRetry(
		t,
		fmt.Sprintf("%s/health", url),
		nil,
		200,
		"ok",
		maxRetries,
		timeBetweenRetries,
	)
}

func TestAPIEndpoint(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	apiUrl := terraform.Output(t, terraformOptions, "api_url")

	// Test API with custom validation
	statusCode, body := http_helper.HttpGet(t, apiUrl, nil)

	assert.Equal(t, 200, statusCode)
	assert.Contains(t, body, "data")
}

Pruebas SSH

Connect to instances and validate configuration via SSH.

SSH connection testing:

// test/ssh_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/aws"
	"github.com/gruntwork-io/terratest/modules/ssh"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSSHConnection(t *testing.T) {
	awsRegion := "us-east-1"
	keyPairName := "test-key"

	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
		Vars: map[string]interface{}{
			"key_name": keyPairName,
		},
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Get instance details
	instanceId := terraform.Output(t, terraformOptions, "instance_id")
	instance := aws.GetEC2Instance(t, instanceId, awsRegion)

	// Get public IP
	publicIp := instance.PublicIpAddress

	// Create SSH host
	host := ssh.Host{
		Hostname:    publicIp,
		SshUserName: "ec2-user",
		SshKeyPair:  getSSHKey(t, keyPairName),
	}

	// Test SSH connection
	cmd := "echo Hello from EC2"
	output := ssh.CheckSshCommand(t, host, cmd)
	assert.Contains(t, output, "Hello from EC2")
}

func TestServerConfiguration(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Get instance and SSH host
	instanceId := terraform.Output(t, terraformOptions, "instance_id")
	instance := aws.GetEC2Instance(t, instanceId, "us-east-1")
	
	host := ssh.Host{
		Hostname:    instance.PublicIpAddress,
		SshUserName: "ubuntu",
		SshKeyPair:  loadPrivateKey(t),
	}

	// Verify Nginx installed
	assert.NoError(t,
		ssh.CheckSshCommandE(t, host, "which nginx"))

	// Verify Nginx running
	output := ssh.CheckSshCommand(t, host, "sudo systemctl is-active nginx")
	assert.Equal(t, "active", output)

	// Check application process
	assert.NoError(t,
		ssh.CheckSshCommandE(t, host, "ps aux | grep myapp"))
}

func getSSHKey(t *testing.T, keyName string) *ssh.KeyPair {
	// Load SSH key from file or AWS
	// Implementation depends on how keys are stored
	return &ssh.KeyPair{}
}

func loadPrivateKey(t *testing.T) *ssh.KeyPair {
	// Load from file
	keypair, err := ssh.NewKeyPairE("~/.ssh/id_rsa")
	require.NoError(t, err)
	return keypair
}

Reintentos y Sondeo

Handle eventual consistency and transient failures.

Retry patterns:

// test/retry_test.go
package test

import (
	"testing"
	"time"
	"github.com/gruntwork-io/terratest/modules/retry"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestWithRetry(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Define retry logic
	maxRetries := 10
	timeBetweenRetries := time.Second * 5

	err := retry.DoWithRetry(
		t,
		"Verify database is ready",
		maxRetries,
		timeBetweenRetries,
		func() error {
			// Code that might fail and should retry
			db := getDatabase(t)
			if db == nil {
				return fmt.Errorf("database not ready")
			}
			return nil
		},
	)

	assert.NoError(t, err)
}

func TestWithTimeoutRetry(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	// Retry with timeout
	maxRetries := 30
	timeBetweenRetries := time.Second

	err := retry.DoWithRetryableErrors(
		t,
		"Wait for service healthy",
		maxRetries,
		timeBetweenRetries,
		map[string]bool{
			"EOF":                                              true,
			"connection refused":                               true,
			"temporary failure in name resolution":             true,
		},
		func() error {
			return checkServiceHealth(t)
		},
	)

	assert.NoError(t, err)
}

Limpieza y Gestión de Recursos

Properly manage resource cleanup and avoid orphaned resources.

Cleanup patterns:

// test/cleanup_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/terraform"
)

func TestWithProperCleanup(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
	}

	// Ensure cleanup runs even if test fails
	defer func() {
		// Manual cleanup
		terraform.Destroy(t, terraformOptions)
		
		// Check for errors
		if r := recover(); r != nil {
			t.Errorf("Test panicked: %v", r)
		}
	}()

	terraform.InitAndApply(t, terraformOptions)

	// Test code...
}

func TestWithSkipDestroy(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../terraform",
		SkipDeploy:   false,
		NoColor:      true,
	}

	// Skip destroy for debugging
	if os.Getenv("SKIP_DESTROY") != "" {
		defer terraform.Destroy(t, terraformOptions)
	}

	terraform.InitAndApply(t, terraformOptions)
}

Integración con CI/CD

Run Terratest in CI/CD pipelines.

GitHub Actions:

# .github/workflows/terratest.yml
name: Terratest

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_DEFAULT_REGION: us-east-1
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-go@v4
        with:
          go-version: '1.20'
      
      - run: go mod download
      
      - run: go test -v -timeout 30m ./test/...

Conclusión

Terratest enables automated testing of infrastructure code, catching bugs and configuration errors before production deployment. By writing Go-based infrastructure tests that provision real resources, validate them, and clean up properly, you gain confidence in your infrastructure automation. Combined with CI/CD integration, Terratest creates a safety net ensuring your infrastructure code is reliable, correct, and production-ready.