Infrastructure as Code testing with Terraform, Test Kitchen, and InSpec
Testing Infrastructure as Code: reliable deployments with Terraform and Kitchen-Terraform
9 min read
Faulty IaC still causes outages and cost spikes. This article lays out a layered test strategy, a Kitchen-Terraform plus InSpec walkthrough for an AWS S3 module, and practices that keep infra tests honest in CI.
Why IaC breaks when testing stays informal
Infrastructure as Code is now central to DevOps, but faulty configurations can still cause outages, security gaps, or surprise bills. Classic test habits often treat infra as secondary to application code, so teams miss environment-specific behavior, module coupling, and drift between declared and deployed state. Without disciplined checks, you get an infrastructure-scale version of “works on my machine”: changes that looked fine locally fail in production because of provider versions, API differences, or hidden dependencies.
A layered strategy: unit, integration, end-to-end, drift
Treat IaC like any critical codebase. Unit-style checks validate modules and syntax. Integration runs prove modules compose in an isolated account or project. End-to-end flows stand up full stacks in disposable environments. Drift detection continuously compares live state to the code definition. Kitchen-Terraform plugs Terraform into Test Kitchen so you can drive real applies and verify outcomes with InSpec against real or mocked providers, giving you a repeatable loop instead of one-off manual clicks.
Example layout for a Terraform module under test
The tree below mirrors a common split: module code at the repository root, Ruby Gemfile for Test Kitchen, a top-level kitchen.yml if you use it, and integration tests under test/integration/default with controls and a suite-scoped kitchen.yml.
terraform-module-s3-bucket/
├── main.tf
├── variables.tf
├── outputs.tf
├── test/
│ ├── integration/
│ │ └── default/
│ │ ├── controls/
│ │ │ └── s3_bucket.rb
│ │ └── kitchen.yml
│ └── unit/
│ └── test_s3_bucket.rb
├── Gemfile
└── kitchen.ymlTerraform module: S3 bucket with versioning and SSE
This minimal module matches current AWS provider behavior (v4 onward): `aws_s3_bucket` only sets the name and tags, while `aws_s3_bucket_versioning` and `aws_s3_bucket_server_side_encryption_configuration` own those settings—nested `versioning` and `server_side_encryption_configuration` blocks on the bucket are read-only and will error if you try to manage them there. Variables stay injectable from Kitchen for unique, disposable resources per run.
variable "bucket_name" {
description = "Name of the S3 bucket"
type = string
}
variable "tags" {
description = "Tags to apply to the bucket"
type = map(string)
default = {}
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}Kitchen-Terraform driver and verifier
The suite pins the Terraform driver and provisioner to the module root, passes a timestamped bucket name plus tags, and points the InSpec verifier at the integration test directory. Ubuntu is a typical platform label even though verification is cloud-side.
driver:
name: terraform
provisioner:
name: terraform
root_folder: ../..
variables:
bucket_name: "test-kitchen-s3-bucket-<%= Time.now.to_i %>"
tags:
Environment: test
Kitchen: verifies
verifier:
name: inspec
platforms:
- name: ubuntu-22.04
suites:
- name: default
verifier:
inspec_tests:
- test/integration/defaultInSpec control against the live bucket
After apply, InSpec uses the AWS API to assert the bucket exists, versioning is on, and an encryption configuration is present. Failures block promotion because they surface real misconfiguration rather than mocked assumptions.
control 's3-bucket-basics' do
impact 1.0
title 'Ensure S3 bucket has versioning and encryption'
describe aws_s3_bucket(bucket_name: input('bucket_name')) do
it { should exist }
its('versioning') { should eq true }
its('server_side_encryption_configuration') { should_not be_empty }
end
endCreate, verify, destroy
The workflow provisions a real bucket in AWS using short-lived credentials, converges Terraform, runs InSpec, then tears resources down. That is slower than mocks but catches provider-specific behavior you only see against the live API.
# Install dependencies
bundle install
# Create and converge the test instance
bundle exec kitchen create
# Run the verification
bundle exec kitchen verify
# Clean up
bundle exec kitchen destroyBest practices that keep IaC tests trustworthy
Isolate environments with workspaces or random suffixes so parallel runs never collide. Prefer real providers (or high-fidelity tools like LocalStack) for integration and end-to-end tiers while keeping fast static checks upstream. Add policy-as-code gates with OPA and Conftest or Sentinel before apply. Wire the suite into pull requests with caching tuned for speed versus depth. Version modules semantically and publish to a private registry so consumers pin known-good releases. Track flaky runs, add bounded retries for transient cloud errors, and separate noise from real regressions. Watch costs for ephemeral stacks. Finally, document each control’s intent so the suite stays approachable as the team grows.
To wire these checks into delivery without guesswork, align the suite with the diagnostics in our release pipeline bottlenecks guide.
When infra tests surface intermittent failures, treat observability and controlled failure practice as companions, as in this Chaos Engineering playbook for DevOps.
