Cloud Architecture

Robust Infra Testing: Terratest & Kitchen for Terraform

Master infrastructure testing for Terraform modules using Terratest and Test Kitchen. Learn to build robust, automated CI/CD pipelines for reliable infrastructure-as-code deployments.

Khader Vali June 29, 2026 15 min read

As a senior software engineer and technical writer at Khadervali.com, my mission is to share practical, in-depth knowledge that empowers my peers to build more resilient and efficient systems. Today, we’re diving deep into a critical aspect of modern cloud development: ensuring the reliability of our infrastructure-as-code (IaC). Specifically, we’ll explore how to achieve robust, automated testing for Terraform modules using two powerful tools: Terratest and Test Kitchen (with Kitchen-Terraform).

In the world of IaC, a single misconfiguration can lead to outages, security vulnerabilities, or significant cost overruns. While static analysis and linting tools catch syntax errors, they can’t tell you if your infrastructure actually *works* as intended. That’s where integration and end-to-end testing come in. By simulating real-world deployments and validating their behavior, we can catch issues early, reduce production incidents, and accelerate our development cycles. Let’s unlock the power of automated infrastructure testing.

The Peril of Untested Infrastructure

Before we jump into solutions, let’s acknowledge the problem. Many organizations still treat infrastructure deployment as a manual or semi-manual process, even with Terraform. They might have a staging environment, but often, the testing strategy boils down to: “deploy it, and if it doesn’t break immediately, it’s good.” This approach is fraught with peril:

  • Drift and Inconsistency: Manual checks lead to human error and environments that subtly diverge over time.
  • Slow Feedback Loops: Issues found in production are expensive and time-consuming to fix, often requiring rollbacks and emergency patches.
  • Lack of Confidence: Developers and operations teams are hesitant to make changes, fearing unintended side effects, slowing down innovation.
  • Security Gaps: Misconfigured security groups, IAM policies, or network rules can expose sensitive data or systems.
  • Cost Overruns: Incorrectly provisioned resources (e.g., oversized VMs, unattached storage) can silently inflate cloud bills.

The solution? Treat your infrastructure code with the same rigor and testing discipline you apply to your application code. This means automated unit, integration, and end-to-end tests that run consistently as part of your CI/CD pipeline.

Understanding the Infrastructure Testing Landscape

When we talk about testing infrastructure, especially Terraform modules, we’re typically looking at a few layers:

  • Static Analysis/Linting: Tools like terraform validate, tflint, and Checkov check for syntax errors, best practices, and security misconfigurations without deploying anything. (Essential, but insufficient).
  • Unit Testing: For Terraform, this often means testing individual resource blocks or small module outputs against expected values without actual deployment or with mocked providers. (Limited, but fast).
  • Integration Testing: Deploying a small, isolated instance of a module or a collection of modules into a temporary environment, then asserting its properties (e.g., verifying an S3 bucket has specific tags, an EC2 instance is running with the correct AMI). This is where Terratest shines.
  • End-to-End Testing: Deploying a more complete system, including applications, and validating the entire stack’s functionality (e.g., deploying a web app, then hitting its endpoint to ensure it responds correctly). This often involves a mix of Terratest and tools like Kitchen-Terraform or even application-level tests.

Today, our focus is primarily on integration and end-to-end testing, as these provide the highest confidence that your infrastructure will behave as expected in a real cloud environment.

Robust Infra Testing: Terratest & Kitchen for Terraform
Generated Image

Terratest: Go-Powered Infrastructure Validation

Terratest is a Go library that provides a framework for writing automated tests for your infrastructure. It allows you to deploy real infrastructure in a temporary environment, make assertions about its state, and then tear it down. This “deploy, test, destroy” cycle is fundamental to robust infrastructure testing.

Why Terratest?

  • Go-Native: If your team is already familiar with Go, Terratest integrates seamlessly with the Go testing framework.
  • Direct Cloud Interaction: It uses cloud provider SDKs (e.g., AWS SDK for Go) to directly inspect resources, providing a high level of confidence.
  • Terraform, Packer, Docker, Kubernetes: While we focus on Terraform, Terratest supports testing across a wide range of IaC tools.
  • Full Lifecycle Testing: It manages the entire lifecycle: initialization, applying changes, validating, and destroying.
  • Parallel Execution: Go’s testing framework supports parallel test execution out of the box, speeding up your test suites.

Core Concepts and How it Works

A typical Terratest workflow looks like this:

  1. Setup: Your Go test code defines the Terraform module to be tested, specifying input variables and potentially a temporary working directory.
  2. Deployment: Terratest executes terraform init and terraform apply to deploy the module into a dedicated, isolated cloud environment (e.g., a specific AWS account or project in GCP).
  3. Validation: Once deployed, Terratest uses the Terraform outputs and cloud provider SDKs to fetch resource attributes. Your test code then asserts these attributes against expected values. This could involve checking resource tags, security group rules, network configurations, or even making API calls to an exposed service.
  4. Teardown: Crucially, after all assertions pass (or fail), Terratest executes terraform destroy to clean up all provisioned resources. This ensures a clean slate for subsequent tests and prevents unexpected cloud costs.

Anatomy of a Terratest Suite

Let’s walk through an example. Imagine you have a simple Terraform module that provisions an S3 bucket:


# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "example" {
  bucket = var.bucket_name
  acl    = var.acl

  tags = merge(
    {
      "Environment" = var.environment
    },
    var.additional_tags
  )
}

resource "aws_s3_bucket_public_access_block" "example" {
  count  = var.block_public_access ? 1 : 0
  bucket = aws_s3_bucket.example.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

variable "bucket_name" {
  description = "The name of the S3 bucket."
  type        = string
}

variable "acl" {
  description = "The ACL to apply to the bucket."
  type        = string
  default     = "private"
}

variable "environment" {
  description = "The environment tag for the bucket."
  type        = string
  default     = "dev"
}

variable "additional_tags" {
  description = "Additional tags for the bucket."
  type        = map(string)
  default     = {}
}

variable "block_public_access" {
  description = "Whether to block all public access to the bucket."
  type        = bool
  default     = true
}

output "bucket_id" {
  description = "The ID of the S3 bucket."
  value       = aws_s3_bucket.example.id
}

output "bucket_arn" {
  description = "The ARN of the S3 bucket."
  value       = aws_s3_bucket.example.arn
}

Now, let’s write a Terratest to validate this module. Create a file named `test/s3_bucket_test.go`:


package test

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gruntwork-io/terratest/modules/aws"
	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
	test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
)

func TestS3BucketModule(t *testing.T) {
	t.Parallel() // Allows parallel execution if you have multiple test functions

	// Store the path to the Terraform module so that we can use it after a retry.
	terraformModulePath := "../modules/s3-bucket"

	// At the end of the test, run `terraform destroy` to clean up any resources that were created.
	defer test_structure.RunTestStage(t, "teardown", func() {
		terraformOptions := test_structure.LoadTerraformOptions(t, terraformModulePath)
		terraform.Destroy(t, terraformOptions)
	})

	// Deploy the module using `terraform init` and `terraform apply`.
	test_structure.RunTestStage(t, "setup", func() {
		uniqueId := random.UniqueId()
		bucketName := fmt.Sprintf("khadervali-test-bucket-%s", strings.ToLower(uniqueId))
		awsRegion := aws.Get  // Ensure AWS_REGION is set in your environment or default to a region.
		if awsRegion == "" {
			awsRegion = "us-east-1" // Default region if not set
		}

		terraformOptions := terraform.With  // Initialize with common options
		terraformOptions.TerraformDir = terraformModulePath
		terraformOptions.Vars = map[string]interface{}{
			"bucket_name":         bucketName,
			"environment":         "test",
			"acl":                 "private",
			"block_public_access": true,
			"additional_tags": map[string]string{
				"Project": "Khadervali",
				"Owner":   "Khadervali",
			},
		}

		test_structure.SaveTerraformOptions(t, terraformModulePath, terraformOptions)

		terraform.InitAndApply(t, terraformOptions)
	})

	// Validate the deployed infrastructure.
	test_structure.RunTestStage(t, "validate", func() {
		terraformOptions := test_structure.LoadTerraformOptions(t, terraformModulePath)
		bucketID := terraform.Output(t, terraformOptions, "bucket_id")
		awsRegion := aws.Get  // Get region from environment or default

		// Create an S3 client
		sess, err := session.NewSession(&aws.Config{
			Region: aws.String(awsRegion),
		})
		if err != nil {
			t.Fatalf("Failed to create AWS session: %v", err)
		}
		s3Client := s3.New(sess)

		// Assertions using the AWS SDK and Terratest helpers
		// 1. Check if the bucket exists
		aws.AssertS3BucketExists(t, awsRegion, bucketID)

		// 2. Check bucket tags
		tags := aws.GetS3BucketTags(t, awsRegion, bucketID)
		expectedTags := map[string]string{
			"Environment": "test",
			"Project":     "Khadervali",
			"Owner":       "Khadervali",
		}
		for key, expectedValue := range expectedTags {
			actualValue, ok := tags[key]
			if !ok {
				t.Fatalf("Expected tag %s not found on bucket %s", key, bucketID)
			}
			if actualValue != expectedValue {
				t.Fatalf("Tag %s on bucket %s has unexpected value. Expected %s, got %s", key, bucketID, expectedValue, actualValue)
			}
		}

		// 3. Check public access block configuration
		input := &s3.GetPublicAccessBlockInput{
			Bucket: aws.String(bucketID),
		}
		result, err := s3Client.GetPublicAccessBlock(input)
		if err != nil {
			t.Fatalf("Failed to get public access block for bucket %s: %v", bucketID, err)
		}

		if *result.PublicAccessBlockConfiguration.BlockPublicAcls != true {
			t.Fatalf("Expected BlockPublicAcls to be true, got false for bucket %s", bucketID)
		}
		if *result.PublicAccessBlockConfiguration.BlockPublicPolicy != true {
			t.Fatalf("Expected BlockPublicPolicy to be true, got false for bucket %s", bucketID)
		}
		// ... add more assertions for other public access block settings if needed
	})
}

To run this test:

  1. Ensure you have Go installed.
  2. Install Terratest: go get github.com/gruntwork-io/terratest/modules/...
  3. Ensure your AWS credentials are configured (e.g., via environment variables or ~/.aws/credentials).
  4. Navigate to the test directory and run: go test -v -timeout 30m (The timeout is important for cloud deployments).

This example demonstrates how Terratest orchestrates the deployment and validation. The test_structure package is particularly useful for managing state across different test stages (setup, validate, teardown), especially when dealing with retries or parallel execution.

Advanced Terratest Techniques

  • Retries: Cloud APIs can be eventually consistent. Terratest provides retry.DoWithRetry to re-attempt assertions until they pass or a timeout is reached.
  • Parallel Testing: Use t.Parallel() to run multiple independent tests concurrently, speeding up your test suite.
  • Error Handling: Terratest functions often return error. Proper error handling ensures robust tests.
  • Helper Functions: Abstract common validation logic into reusable helper functions to keep your tests DRY (Don’t Repeat Yourself).
  • CI/CD Integration: Terratest fits naturally into CI/CD pipelines. A typical GitHub Actions, GitLab CI, or Jenkins job would involve setting up Go, configuring cloud credentials, and then running go test.

Test Kitchen with Kitchen-Terraform: Deeper OS-Level Validation

While Terratest excels at validating the infrastructure itself (e.g., S3 bucket properties, EC2 instance types, security group rules), what if you need to test what’s *inside* an EC2 instance, or how multiple modules interact at a deeper level, perhaps even involving application deployment? This is where Test Kitchen, combined with the Kitchen-Terraform driver, becomes incredibly powerful.

What is Test Kitchen?

Test Kitchen is an integration testing framework, originally designed for Chef cookbooks, but extensible through “drivers” and “provisioners.” It provides a structured way to:

  1. Spin up instances: Using drivers (e.g., EC2, Docker, Vagrant).
  2. Provision instances: Installing software, applying configuration (e.g., Chef, Ansible, shell scripts).
  3. Run tests: Using verifiers (e.g., InSpec, Serverspec) to assert the state of the instance.
  4. Destroy instances: Clean up after tests.

Kitchen-Terraform is a Test Kitchen driver that allows you to use Terraform to provision the instances that Test Kitchen will then test. This is a game-changer because it means you can test your *actual* Terraform code that deploys VMs, network components, etc., and then use Kitchen’s verifier capabilities to test the operating system and application configuration on those VMs.

Why Kitchen-Terraform?

  • OS-Level Validation: InSpec and Serverspec (common verifiers) allow you to assert OS packages, services, file contents, user accounts, and even network connectivity from *within* the instance.
  • Multi-Tool Integration: Excellent for testing scenarios where Terraform provisions the infrastructure, and then a configuration management tool (like Ansible or Chef) configures the instances.
  • Structured Workflow: Test Kitchen provides a clear, convention-over-configuration workflow for defining test suites.
  • Broader Ecosystem: Leverages the mature Test Kitchen ecosystem of drivers and verifiers.
  • Readability: `.kitchen.yml` and InSpec/Serverspec tests are often more readable for non-Go developers compared to Terratest’s Go code.

Core Concepts and How it Works

A typical Kitchen-Terraform workflow:

  1. Driver (Kitchen-Terraform): Your .kitchen.yml specifies kitchen-terraform as the driver. This driver understands how to execute Terraform.
  2. Provisioner (Terraform): The kitchen-terraform driver points to your Terraform module. It runs terraform init, terraform plan (optionally), and terraform apply to provision the infrastructure. It extracts outputs from Terraform, which can then be used by the verifier.
  3. Verifier (InSpec/Serverspec): Once the infrastructure is up, Test Kitchen uses SSH (or other protocols) to connect to the provisioned instances. It then executes the specified verifier tests (e.g., InSpec profiles) on those instances to validate their internal state.
  4. Teardown: After tests, terraform destroy is called by the driver to clean up all resources.

Anatomy of a Kitchen-Terraform Suite

Let’s imagine you have a Terraform module that provisions an EC2 instance and installs Nginx on it using a user_data script:


# modules/nginx-instance/main.tf
resource "aws_vpc" "example" {
  cidr_block = "10.0.0.0/16"
  tags = { Name = "test-vpc" }
}

resource "aws_subnet" "example" {
  vpc_id     = aws_vpc.example.id
  cidr_block = "10.0.1.0/24"
  availability_zone = "us-east-1a" # Or use data source to get AZ
  tags = { Name = "test-subnet" }
}

resource "aws_security_group" "nginx_sg" {
  name        = "nginx-sg"
  description = "Allow HTTP and SSH"
  vpc_id      = aws_vpc.example.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "nginx-sg" }
}

data "aws_ami" "ubuntu" {
  most_recent = true
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
  owners = ["099720109477"] # Canonical
}

resource "aws_key_pair" "deployer" {
  key_name   = "deployer-key-${random_id.instance_id.hex}"
  public_key = var.public_key_openssh
}

resource "random_id" "instance_id" {
  byte_length = 8
}

resource "aws_instance" "nginx_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  subnet_id     = aws_subnet.example.id
  vpc_security_group_ids = [aws_security_group.nginx_sg.id]
  key_name      = aws_key_pair.deployer.key_name

  user_data = <<-EOF
              #!/bin/bash
              sudo apt update -y
              sudo apt install -y nginx
              sudo systemctl enable nginx
              sudo systemctl start nginx
              EOF

  tags = {
    Name        = "nginx-server-${random_id.instance_id.hex}"
    Environment = var.environment
  }
}

variable "instance_type" {
  description = "The EC2 instance type."
  type        = string
  default     = "t3.micro"
}

variable "environment" {
  description = "The environment for the EC2 instance."
  type        = string
  default     = "dev"
}

variable "public_key_openssh" {
  description = "Public key for SSH access (e.g., from ~/.ssh/id_rsa.pub)"
  type        = string
}

output "instance_public_ip" {
  description = "Public IP address of the EC2 instance."
  value       = aws_instance.nginx_server.public_ip
}

output "instance_id" {
  description = "ID of the EC2 instance."
  value       = aws_instance.nginx_server.id
}

output "security_group_id" {
  description = "ID of the security group."
  value       = aws_security_group.nginx_sg.id
}

output "private_key_pem" {
  description = "The private key in PEM format (for Kitchen SSH access)"
  value       = aws_key_pair.deployer.private_key_pem
  sensitive   = true
}

We’ll need to generate an SSH key pair to allow Test Kitchen to connect. For testing purposes, you might generate a temporary one, or use an existing one.


ssh-keygen -t rsa -b 4096 -f ./test_id_rsa -N ""
# Then copy the public key content into `public_key_openssh` variable.
# For example: `cat test_id_rsa.pub`

Now, let’s create a .kitchen.yml file in the root of your project:


---
driver:
  name: terraform
  # Path to your Terraform module under test
  directory: modules/nginx-instance
  # Variables to pass to Terraform
  variables:
    instance_type: "t3.small" # Use a slightly larger instance for faster setup/testing if needed
    environment: "kitchen-test"
    public_key_openssh: | # Ensure this matches your generated public key
      ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQD
      ... your public key content ...
      khadervali@local-machine

provisioner:
  name: terraform

verifier:
  name: inspec
  # Where your InSpec profiles are located
  directory: test/integration

platforms:
  # This section defines the 'platform' which will mostly map to your Terraform outputs
  # Kitchen-Terraform will use these to determine how to connect to the instances it needs to verify.
  - name: default
    # The output variable from Terraform that contains the public IP address of the instance
    # Test Kitchen will use this to connect.
    main_resource_name: "aws_instance.nginx_server"
    resource_address_attribute: public_ip
    # The output variable that contains the private key for SSH access.
    # THIS IS CRITICAL for Kitchen to connect to the instance.
    private_key_attribute: private_key_pem
    ssh_key: ./test_id_rsa # Path to the generated private key file
    ssh_user: ubuntu # Default user for Ubuntu AMIs

suites:
  - name: default
    # If you have specific inputs or overrides for this test suite, define them here.
    # For instance, if you want to test different instance types.
    # vars:
    #   instance_type: "t3.medium"

Next, create the InSpec profile to verify the Nginx installation. Create test/integration/default/controls/nginx_spec.rb:


# test/integration/default/controls/nginx_spec.rb
control 'nginx-installation' do
  impact 1.0
  title 'Nginx should be installed and running'
  desc 'Ensures Nginx package is installed, service is running, and HTTP port is open.'

  describe package('nginx') do
    it { should be_installed }
  end

  describe service('nginx') do
    it { should be_enabled }
    it { should be_running }
  end

  describe port(80) do
    it { should be_listening }
  end

  describe http('http://localhost') do
    its('status') { should cmp 200 }
    its('body') { should include 'Welcome to nginx!' }
  end
end

To run these tests:

  1. Install Ruby and Bundler.
  2. Install Test Kitchen and Kitchen-Terraform:
    
            gem install test-kitchen kitchen-terraform inspec
            # You might also need the AWS Kitchen driver if you intend to use it directly,
            # but for kitchen-terraform, it's typically just the terraform driver.
            
  3. Ensure your AWS credentials are configured.
  4. Generate your SSH key pair as shown above.
  5. Run: kitchen test

kitchen test will execute the entire lifecycle: create the EC2 instance via Terraform, wait for it to be ready, run the InSpec tests via SSH, and then destroy the resources. You can also run stages individually: kitchen converge (applies Terraform), kitchen verify (runs verifier), kitchen destroy (cleans up).

Robust Infra Testing: Terratest & Kitchen for Terraform
Generated Image

Architecture for Robust Infrastructure Testing

Integrating Terratest and Kitchen-Terraform into a cohesive testing strategy requires a thoughtful approach. Here’s a conceptual architecture, described in words, illustrating how these tools fit into a modern CI/CD pipeline:

The Infrastructure Testing Pyramid (in words)

Just like application code, infrastructure testing benefits from a pyramid structure:

  1. Base (Unit/Static Analysis): Fast, cheap tests
Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

GitOps with ArgoCD: Managing Kubernetes Deployments

Jun 18, 2026 · 16 min read

Micro-Frontends with Webpack Module Federation

Oct 06, 2024 · 2 min read

FinOps: Data-Driven Cloud Cost Optimization for Engineers

Jun 21, 2026 · 18 min read