How to test infrastructure as code before deployment. Covers unit testing Terraform, policy-as-code with OPA, integration testing, drift detection, and CI/CD for infrastructure.
Infrastructure as Code (IaC) testing strategies are the deliberate, repeatable processes used to validate the correctness, consistency, and reliability of infrastructure definitions before and after deployment. They matter when IaC becomes the single source of truth for environments—when changes to main in a Git repository trigger a complete infrastructure rollout, and a misconfigured ec2_instance.tf can break production for hours.
Before deploying infrastructure, the primary goal is to catch syntax errors, configuration drift, and logical inconsistencies early. This phase uses static and dynamic analysis tools to verify IaC across multiple dimensions.
The first line of defense is ensuring the IaC files are syntactically valid and follow agreed-upon patterns. Tools like terraform fmt, ansible-lint, and yamllint provide immediate feedback.
terraform fmt -recursive
terraform validate -no-color
terraform validate checks that all required variables are provided, modules are correctly referenced, and backend configurations are valid. It fails if a required variable is missing, even if it’s not in terraform.tfvars.
# Example: missing required variable
variable "app_name" {
type = string
# missing "description" and "default"
}
Error: Required variable not set
on main.tf line 15:
15: app_name = "web-app"
Variable app_name is required but was not set.
- module "web_server" has a variable named app_name, but no value was provided.
This error is silent if terraform validate is not run, causing deployments to fail in CI without clear feedback.
For Ansible, ansible-lint enforces best practices and catches common mistakes:
---
# roles/web/tasks/main.yml
- name: Install Nginx
apt:
name: nginx
state: present
become: yes
# Run linting with strict rules
ansible-lint -c .ansible-lint --profile=default --fail-on=warning
The .ansible-lint configuration file defines which rules to apply:
# .ansible-lint
rules:
# Enforce idempotency
- name: task-order
- name: no-missing-requirements
- name: no-unnecessary-roles
- name: no-unsafe-permissions
- name: playbook-should-have-tags
- name: file-must-exist
- name: meta-main-must-have-author
A common silent failure: ansible-lint reports no-unnecessary-roles, but the role is never used in the playbook. The tool checks only roles/ directory content and meta/main.yml, but does not infer usage from include_role or import_role tasks. This leads to wasted effort in cleaning up unused roles—until a reviewer notices the discrepancy.
To verify that IaC produces expected infrastructure, run it locally using a containerized environment.
# Build a test environment with Docker Compose
docker-compose up -d --build
# docker-compose.yml
version: '3.8'
services:
terraform:
image: hashicorp/terraform:latest
volumes:
- .:/workspace
working_dir: /workspace
command: sh -c "terraform init && terraform apply -auto-approve"
depends_on:
- aws-cli
aws-cli:
image: amazon/aws-cli
volumes:
- ~/.aws:/root/.aws
command: sleep infinity
This setup runs terraform apply in a fresh container with a real AWS CLI environment. The ~/.aws volume ensures credentials and region settings are consistent across runs. A common failure: terraform apply runs with AWS_PROFILE=dev, but the aws-cli service uses AWS_DEFAULT_PROFILE, leading to mismatched credentials. The error is silent unless aws sts get-caller-identity is explicitly called.
Once infrastructure is deployed, the focus shifts to verifying that the actual running system matches the IaC definition. This includes checking configuration, service health, and network behavior.
Use terraform state to compare expected and actual state after deployment.
# After a deployment, export state and compare
terraform state pull
terraform state show -json > state.json
Compare the state.json with a known good baseline using jq:
# Compare state against expected baseline
jq -S -c -n --argfile baseline state.json \
--argfile expected expected.json \
'($baseline | {id: .resource | to_entries | map(.value.id) | sort | join(",")) |
$expected | {id: .resource | to_entries | map(.value.id) | sort | join(",")} |
.id | select(.baseline != .expected) |
{message: "State mismatch", baseline: .baseline, expected: .expected} | .'
A subtle failure: terraform state show includes the id field, but not the name or tags. If the IaC defines tags = {Name: "web-server"}, but the actual EC2 instance has Name: "web-server-prod", state show will miss the difference unless jq is used to extract and compare tags.
Use kubectl and systemctl to verify that services are running and configured as expected.
# Check that a Kubernetes pod is ready and has correct env vars
kubectl get pod -l app=web-server -o jsonpath='{.items[*].status.conditions[?(@.type=="Ready")].status}' | grep -q "True"
# Verify environment variables in a systemd service
systemctl show nginx.service --property=Environment --value
The --value flag returns only the values, one per line. A common mistake: using systemctl show nginx.service without --value, resulting in a verbose output that includes keys and values in a single column. The expected output is:
PATH=/usr/local/bin:/usr/bin:/bin
APP_ENV=production
LOG_LEVEL=info
But the actual output is:
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=APP_ENV=production
Environment=LOG_LEVEL=info
This leads to brittle tests: grep or awk scripts expect one line per value, but get one line per key-value pair.
After deployment, run integration tests that simulate real usage. Use curl, pgbench, and docker exec.
# Test that the web server responds with correct headers
curl -s -H "Host: api.example.com" http://localhost:8080/health \
| jq -e '.status == "UP" and .version == "v1.2.0"'
If the response is:
{
"status": "UP",
"version": "v1.2.0",
"db": {
"status": "connected",
"latency_ms": 12
}
}
Then jq -e fails only if all conditions are met. A subtle issue: jq -e exits with status 1 if any test fails, but does not print the failing test. The solution is to include --compact-output and --raw-output:
curl -s http://localhost:8080/health \
| jq --compact-output --raw-output -e '
.status == "UP" and
.version == "v1.2.0" and
.db.status == "connected"
'
This produces a clean, concise output in CI logs, making failures easier to debug.
IaC often defines templates that generate configuration files. Validate these by rendering the templates and checking their output.
For Jinja2-based templates (used in Ansible, Helm, and custom IaC scripts), render templates and compare output.
# Render a Jinja2 template with variables
jinja2 templates/nginx.conf.j2 -D app_name=web-app -D port=8080 -D ssl=true \
> generated/nginx.conf
Then validate the rendered output:
# Validate rendered NGINX config with nginx -t
nginx -t -c generated/nginx.conf
A silent failure: nginx -t returns success if the config is valid, but does not report the actual parsed config. The error is discovered only when nginx -T is used:
nginx -T -c generated/nginx.conf
This outputs the full, expanded configuration, including defaults. A common mistake: using nginx -t but expecting nginx -T in CI, leading to unnoticed configuration drift.
Orchestrate end-to-end tests using shell scripts that combine multiple tools.
#!/bin/bash
# test-deployment.sh
set -euo pipefail
export AWS_PROFILE=prod
echo "Starting IaC validation for production"
# 1. Run Terraform validation and apply
terraform validate
terraform apply -auto-approve
# 2. Verify EC2 instance
INSTANCE_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=web-server" --query "Reservations[0].Instances[0].InstanceId" --output text)
# 3. Wait for instance to be ready
aws ec2 wait instance-status-ok --instance-ids $INSTANCE_ID
# 4. SSH into instance and check service
ssh -i ~/.ssh/id_rsa ubuntu@$INSTANCE_ID "
systemctl status nginx.service --no-pager
curl -s http://localhost:8080/health | jq -e '.status == \"UP\"'
"
# 5. Validate Kubernetes deployment
kubectl rollout status deployment/web-server -n production --timeout=300s
# 6. Final success message
echo "✅ All tests passed"
The script uses set -euo pipefail to catch errors: set -e exits on any command failure, set -u catches undefined variables, and set -o pipefail ensures that pipelines fail if any stage fails.
A common edge case: aws ec2 wait instance-status-ok returns success, but the EC2 instance has Status=Pending and StatusReason=Instance booting. The wait command only checks Status and StatusReason, but does not verify that the instance is actually running and reachable. The solution is to add a curl test after the wait:
# After wait, check that the instance responds
INSTANCE_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID --query "Reservations[0].Instances[0].PublicIpAddress" --output text)
curl -s http://$INSTANCE_PUBLIC_IP/health | jq -e '.status == "UP"'
Integrate IaC testing into the CI/CD pipeline to catch regressions early.
Use git hooks to run tests on every commit.
# .git/hooks/pre-commit
#!/bin/bash
# Run IaC tests on every commit
echo "Running IaC tests..."
# Linting
ansible-lint -c .ansible-lint .
terraform fmt -check
terraform validate
# Static checks
terraform plan -out=tfplan -no-color
# Dynamic tests
docker-compose up -d --build
docker-compose exec terraform sh -c "terraform apply -auto-approve"
docker-compose exec aws-cli sh -c "
aws ec2 wait instance-status-ok --instance-ids i-1234567890ab
curl -s http://10.0.0.10/health | jq -e '.status == \"UP\"'
"
# Final success
echo "✅ IaC tests passed"
exit 0
The hook runs on every commit, but fails silently if docker-compose up does not start services. The solution is to add --no-deps and --detach flags, and to verify service status.
# .github/workflows/terraform-ci.yml
name: IaC Testing
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Terraform
uses: hashicorp/setup-terraform@v2
with:
tf_version: 1.5.0
- name: Terraform validate
run: terraform validate
- name: Terraform fmt
run: terraform fmt -check
- name: Terraform plan
run: terraform plan -out=tfplan
- name: Run integration tests
run: |
docker-compose up -d --build
docker-compose exec terraform terraform apply -auto-approve
docker-compose exec aws-cli sh -c "
aws ec2 wait instance-status-ok --instance-ids i-1234567890ab
curl -s http://10.0.0.10/health | jq -e '.status == \"UP\"'
"
The job fails if terraform plan generates a new plan, but the terraform plan step does not output the plan file. The solution is to use terraform show -json tfplan and compare it with a baseline.
terraform init: terraform apply runs without terraform init, leading to missing providers and modules.terraform.tfstate: The state file is not committed, causing terraform apply to use a local state but not the one in the repository.AWS_PROFILE: aws CLI commands use AWS_PROFILE=dev, but the ~/.aws/config file defines default and prod, leading to mismatched credentials.ansible-lint not run in CI: The tool reports warnings, but the CI job does not fail on warnings, causing unnoticed issues.docker-compose up without --build: Services use old images, leading to mismatched versions between CI and production.jq -e with incorrect JSON paths: jq fails silently if the expected path does not exist, but does not report the missing key.These failures are subtle, hard to debug, and often discovered only after a deployment breaks. They are caught only by a disciplined, well-orchestrated IaC testing strategy.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy