Saltar al contenido

Patrones de pruebas de infraestructura con Terratest y Pulumi

Pruebas de infraestructura con Terratest y Pulumi: desde pruebas unitarias de configuración hasta integración en entornos reales con desmontaje automático.

5 min de lectura
Pirámide de pruebas de infraestructura que muestra pruebas unitarias para la configuración, pruebas de integración para los recursos aprovisionados y pruebas de extremo a extremo para la validación completa del sistema

El código de infraestructura merece el mismo rigor de pruebas que el código de aplicación, pero los patrones de prueba son fundamentalmente distintos. No se puede probar unitariamente una VPC de la misma forma en que se prueba una función: hay infraestructura de nube real involucrada, los despliegues tardan minutos y los fallos dejan recursos en ejecución que cuestan dinero.

Terratest y los frameworks de pruebas de Pulumi abordan este problema de forma distinta, pero se complementan bien. Terratest envuelve Terraform con pruebas de integración basadas en Go. El SDK de pruebas de Pulumi ofrece pruebas unitarias que simulan proveedores de nube y pruebas de integración que despliegan de verdad. Entender cuándo usar cada una ahorra tiempo y gasto en la nube.

Pruebas unitarias de la configuración de infraestructura

Las pruebas unitarias para infraestructura validan la forma y las propiedades de los recursos sin desplegar nada. Se ejecutan en milisegundos y detectan errores de configuración de forma temprana.

gogo
// ❌ No tests — configuration errors discovered in production
// main.tf just applies and you hope it works
resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"
  // Forgot versioning, encryption, public access block...
}
tstypescript
// ✅ Pulumi unit tests — validate resource properties before deploy
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// The infrastructure module under test
function createSecureBucket(
  name: string
): aws.s3.Bucket {
  const bucket = new aws.s3.Bucket(name, {
    versioning: { enabled: true },
    serverSideEncryptionConfiguration: {
      rule: {
        applyServerSideEncryptionByDefault: {
          sseAlgorithm: "aws:kms",
        },
      },
    },
  });
 
  new aws.s3.BucketPublicAccessBlock(
    `${name}-public-access`,
    {
      bucket: bucket.id,
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true,
    }
  );
 
  return bucket;
}
 
// Unit test — no cloud resources created
describe("Secure S3 Bucket", () => {
  let resources: any[];
 
  beforeAll(() => {
    // Mock Pulumi runtime
    pulumi.runtime.setMocks({
      newResource: (args) => {
        resources.push(args);
        return { id: `${args.name}-id`, state: args.inputs };
      },
      call: (args) => args.inputs,
    });
    resources = [];
  });
 
  it("should enable versioning", async () => {
    const bucket = createSecureBucket("test-bucket");
 
    const versioning = await new Promise((resolve) =>
      bucket.versioning.apply((v) => resolve(v))
    );
 
    expect(versioning).toEqual({ enabled: true });
  });
 
  it("should create a public access block", () => {
    createSecureBucket("test-bucket");
 
    const publicAccessBlock = resources.find(
      (r) => r.type === "aws:s3:BucketPublicAccessBlock"
    );
 
    expect(publicAccessBlock).toBeDefined();
    expect(publicAccessBlock.inputs.blockPublicAcls).toBe(
      true
    );
    expect(publicAccessBlock.inputs.blockPublicPolicy).toBe(
      true
    );
  });
 
  it("should use KMS encryption", async () => {
    const bucket = createSecureBucket("test-bucket");
 
    const encryption = await new Promise((resolve) =>
      bucket.serverSideEncryptionConfiguration.apply((v) =>
        resolve(v)
      )
    );
 
    expect(encryption).toEqual({
      rule: {
        applyServerSideEncryptionByDefault: {
          sseAlgorithm: "aws:kms",
        },
      },
    });
  });
});

Estas pruebas validan que el módulo de infraestructura produzca las configuraciones de recursos esperadas. Detectan problemas como cifrado ausente, versionado deshabilitado o controles de acceso faltantes sin esperar ni un minuto por un plan de Terraform.

Pruebas de integración con Terratest

Las pruebas de integración despliegan infraestructura real, validan que funcione y la desmontan. Terratest es la herramienta consolidada para esto con Terraform.

gogo
package test
 
import (
	"crypto/tls"
	"fmt"
	"testing"
	"time"
 
	"github.com/gruntwork-io/terratest/modules/http-helper"
	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
)
 
func TestWebServerModule(t *testing.T) {
	t.Parallel()
 
	uniqueID := random.UniqueId()
	instanceName := fmt.Sprintf("test-web-%s", uniqueID)
 
	terraformOptions := terraform.WithDefaultRetryableErrors(
		t,
		&terraform.Options{
			TerraformDir: "../modules/web-server",
			Vars: map[string]interface{}{
				"instance_name": instanceName,
				"environment":   "test",
				"instance_type": "t3.micro",
			},
			// Prevent destruction on first pass for debugging
			// Comment out for CI
			// EnvVars: map[string]string{
			// 	"SKIP_destroy": "true",
			// },
		},
	)
 
	// Always clean up resources when test finishes
	defer terraform.Destroy(t, terraformOptions)
 
	// Deploy the infrastructure
	terraform.InitAndApply(t, terraformOptions)
 
	// Retrieve outputs for validation
	publicIP := terraform.Output(t, terraformOptions, "public_ip")
	instanceID := terraform.Output(
		t,
		terraformOptions,
		"instance_id",
	)
 
	// Validate the server is responding
	url := fmt.Sprintf("http://%s:80", publicIP)
	tlsConfig := tls.Config{InsecureSkipVerify: false}
 
	http_helper.HttpGetWithRetryWithCustomValidation(
		t,
		url,
		&tlsConfig,
		30,           // max retries
		5*time.Second, // sleep between retries
		func(status int, body string) bool {
			return status == 200
		},
	)
 
	// Validate instance tags
	expectedTags := map[string]string{
		"Name":        instanceName,
		"Environment": "test",
		"ManagedBy":   "terraform",
	}
 
	actualTags := terraform.OutputMap(
		t,
		terraformOptions,
		"instance_tags",
	)
 
	for key, expected := range expectedTags {
		actual, exists := actualTags[key]
		if !exists {
			t.Errorf("Expected tag %s not found", key)
		}
		if actual != expected {
			t.Errorf(
				"Tag %s: expected %s, got %s",
				key,
				expected,
				actual,
			)
		}
	}
}

El patrón defer terraform.Destroy garantiza la limpieza incluso cuando las pruebas fallan. Ejecutar las pruebas en paralelo con identificadores únicos evita conflictos de nombres de recursos cuando se ejecutan varias corridas de prueba simultáneamente en CI.

Pruebas de configuración de red

Las pruebas de infraestructura de red verifican que los grupos de seguridad, el enrutamiento y la conectividad funcionen correctamente después del despliegue.

gogo
func TestVPCNetworkIsolation(t *testing.T) {
	t.Parallel()
 
	uniqueID := random.UniqueId()
 
	terraformOptions := terraform.WithDefaultRetryableErrors(
		t,
		&terraform.Options{
			TerraformDir: "../modules/vpc",
			Vars: map[string]interface{}{
				"vpc_name":   fmt.Sprintf("test-vpc-%s", uniqueID),
				"cidr_block": "10.0.0.0/16",
				"environment": "test",
			},
		},
	)
 
	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)
 
	vpcID := terraform.Output(t, terraformOptions, "vpc_id")
	privateSubnetIDs := terraform.OutputList(
		t,
		terraformOptions,
		"private_subnet_ids",
	)
	publicSubnetIDs := terraform.OutputList(
		t,
		terraformOptions,
		"public_subnet_ids",
	)
 
	// Verify subnet counts
	if len(privateSubnetIDs) < 2 {
		t.Errorf(
			"Expected at least 2 private subnets, got %d",
			len(privateSubnetIDs),
		)
	}
 
	if len(publicSubnetIDs) < 2 {
		t.Errorf(
			"Expected at least 2 public subnets, got %d",
			len(publicSubnetIDs),
		)
	}
 
	// Verify private subnets don't have public IPs
	for _, subnetID := range privateSubnetIDs {
		subnet := aws.GetSubnet(t, subnetID, "us-east-1")
		if subnet.MapPublicIpOnLaunch {
			t.Errorf(
				"Private subnet %s has public IP mapping enabled",
				subnetID,
			)
		}
	}
 
	// Verify VPC has expected CIDR
	vpc := aws.GetVpcById(t, vpcID, "us-east-1")
	if vpc.CidrBlock != "10.0.0.0/16" {
		t.Errorf("VPC CIDR: expected 10.0.0.0/16, got %s", vpc.CidrBlock)
	}
}

Estructuración del pipeline de pruebas

Las pruebas de infraestructura son lentas. Organizarlas en niveles y ejecutarlas de forma selectiva mantiene ágil el ciclo de retroalimentación.

ymlyaml
# .github/workflows/infra-test.yml
name: Infrastructure Tests
 
on:
  pull_request:
    paths:
      - 'infra/**'
      - 'modules/**'
 
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Pulumi unit tests
        run: |
          cd infra
          npm ci
          npm test -- --testPathPattern=unit
 
  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    concurrency:
      group: infra-integration-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      - name: Run Terratest integration tests
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_TEST_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_TEST_SECRET }}
          AWS_DEFAULT_REGION: us-east-1
        run: |
          cd test
          go test -v -timeout 30m -run TestBasic ./...
 
  full-stack-tests:
    runs-on: ubuntu-latest
    needs: integration-tests
    if: github.event.pull_request.labels.*.name == 'infra-full-test'
    steps:
      - uses: actions/checkout@v4
      - name: Run full stack tests
        run: |
          cd test
          go test -v -timeout 60m ./...

El grupo de concurrencia garantiza que solo se ejecute una prueba de integración por rama de PR, evitando conflictos de recursos. Las pruebas de pila completa solo se ejecutan cuando se activan explícitamente mediante una etiqueta, ya que son las más costosas.

Conclusiones clave

Las pruebas unitarias para infraestructura que usan proveedores simulados detectan errores de configuración —cifrado ausente, etiquetas incorrectas, controles de seguridad faltantes— en milisegundos y sin ningún despliegue en la nube. Las pruebas de integración de Terratest despliegan recursos reales, verifican que funcionen y los destruyen automáticamente con defer terraform.Destroy, garantizando la limpieza incluso si la prueba falla. Los identificadores únicos mediante random.UniqueId() y t.Parallel() permiten que las pruebas de integración se ejecuten de forma concurrente sin colisiones de nombres de recursos. Las pruebas de red deben verificar las propiedades críticas para la seguridad: que las subredes privadas no expongan IPs públicas, que los grupos de seguridad restrinjan el acceso correctamente y que las tablas de enrutamiento aíslen el tráfico. Un pipeline por niveles —pruebas unitarias en cada push, pruebas de integración en los PR, pruebas de pila completa ante activación explícita— equilibra la velocidad de retroalimentación con el costo en la nube. La combinación de las pruebas unitarias de Pulumi para una validación rápida de recursos y las pruebas de integración de Terratest para la verificación de despliegues reales cubre todo el espectro de pruebas de infraestructura, desde segundos hasta minutos.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX