Muster für Infrastruktur-Tests mit Terratest und Pulumi
Praktische Infrastruktur-Tests mit Terratest und Pulumi: von Unit-Tests der Ressourcenkonfiguration bis zu Integrationstests in echten Cloud-Umgebungen.

Infrastrukturcode verdient die gleiche testtechnische Sorgfalt wie Anwendungscode, aber die Testmuster sehen grundlegend anders aus. Eine VPC lässt sich nicht auf dieselbe Weise unit-testen wie eine Funktion – es ist reale Cloud-Infrastruktur im Spiel, Deployments dauern Minuten, und bei Fehlschlägen bleiben Ressourcen laufen, die Geld kosten.
Terratest und die Testframeworks von Pulumi gehen dieses Problem unterschiedlich an, ergänzen sich aber gut. Terratest umhüllt Terraform mit Go-basierten Integrationstests. Das Test-SDK von Pulumi bietet Unit-Tests, die Cloud-Provider simulieren, sowie Integrationstests, die tatsächlich deployen. Zu wissen, wann man welches Verfahren einsetzt, spart sowohl Zeit als auch Cloud-Kosten.
Unit-Tests für die Infrastrukturkonfiguration
Unit-Tests für Infrastruktur prüfen die Form und die Eigenschaften von Ressourcen, ohne etwas zu deployen. Sie laufen in Millisekunden ab und decken Konfigurationsfehler frühzeitig auf.
// ❌ 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...
}// ✅ 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",
},
},
});
});
});Diese Tests bestätigen, dass das Infrastrukturmodul die erwarteten Ressourcenkonfigurationen erzeugt. Sie erkennen Probleme wie fehlende Verschlüsselung, deaktivierte Versionierung oder fehlende Zugriffskontrollen, ohne eine einzige Minute auf einen Terraform-Plan zu warten.
Integrationstests mit Terratest
Integrationstests deployen echte Infrastruktur, prüfen, ob sie funktioniert, und bauen sie wieder ab. Terratest ist dafür das etablierte Werkzeug für Terraform.
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,
)
}
}
}Das Muster defer terraform.Destroy stellt die Bereinigung auch dann sicher, wenn Tests fehlschlagen. Das parallele Ausführen von Tests mit eindeutigen IDs verhindert Namenskonflikte bei Ressourcen, wenn mehrere Testläufe gleichzeitig in der CI laufen.
Tests der Netzwerkkonfiguration
Netzwerk-Infrastrukturtests überprüfen, ob Sicherheitsgruppen, Routing und Konnektivität nach dem Deployment korrekt funktionieren.
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)
}
}Aufbau der Test-Pipeline
Infrastrukturtests sind langsam. Sie in Stufen zu organisieren und selektiv auszuführen, hält die Feedback-Schleife kurz.
# .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 ./...Die Concurrency-Gruppe stellt sicher, dass pro PR-Branch nur ein Integrationstest läuft, wodurch Ressourcenkonflikte vermieden werden. Full-Stack-Tests laufen nur, wenn sie explizit über ein Label ausgelöst werden, da sie am teuersten sind.
Die wichtigsten Erkenntnisse
Unit-Tests für Infrastruktur mit simulierten Providern decken Konfigurationsfehler – fehlende Verschlüsselung, falsche Tags, fehlende Sicherheitskontrollen – in Millisekunden auf, ganz ohne Cloud-Deployment. Terratest-Integrationstests deployen echte Ressourcen, prüfen, ob sie funktionieren, und zerstören sie automatisch mit defer terraform.Destroy, was die Bereinigung selbst bei fehlgeschlagenen Tests sicherstellt. Eindeutige IDs über random.UniqueId() und t.Parallel() ermöglichen es, Integrationstests parallel auszuführen, ohne dass Ressourcennamen kollidieren. Netzwerktests sollten die sicherheitskritischen Eigenschaften prüfen: dass private Subnetze keine öffentlichen IPs zuweisen, dass Sicherheitsgruppen den Zugriff korrekt einschränken und dass Routing-Tabellen den Datenverkehr isolieren. Eine gestufte Pipeline – Unit-Tests bei jedem Push, Integrationstests bei PRs, Full-Stack-Tests bei expliziter Auslösung – balanciert Feedback-Geschwindigkeit gegen Cloud-Kosten. Die Kombination aus Pulumis Unit-Tests für schnelle Ressourcenvalidierung und Terratests Integrationstests für die Verifikation echter Deployments deckt das gesamte Spektrum der Infrastrukturtests ab, von Sekunden bis Minuten.


