skip to content
Astro Cactus

Testing and CI/CD Integration with Terraform

/ 5 min read

Series Navigation

Testing Strategies

Unit Testing with Terratest

test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVPCCreation(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
"region": "us-west-2",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
}

Integration Testing

test/integration_test.go
func TestFullStackDeployment(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../environments/test",
Vars: map[string]interface{}{
"environment": "test",
"region": "us-west-2",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Test VPC
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
// Test Subnets
privateSubnets := terraform.Output(t, terraformOptions, "private_subnet_ids")
assert.Greater(t, len(privateSubnets), 0)
// Test Load Balancer
lbDNS := terraform.Output(t, terraformOptions, "lb_dns_name")
assert.NotEmpty(t, lbDNS)
}

Static Analysis

.tflint.hcl
plugin "aws" {
enabled = true
version = "0.21.1"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_deprecated_index" {
enabled = true
}
rule "terraform_unused_declarations" {
enabled = true
}
rule "terraform_documented_variables" {
enabled = true
}

CI/CD Pipeline Integration

GitHub Actions Workflow

.github/workflows/terraform.yml
name: Terraform CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Terraform
uses: hashicorp/setup-terraform@v1
with:
terraform_version: 1.0.0
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

GitLab CI Pipeline

.gitlab-ci.yml
image:
name: hashicorp/terraform:1.0.0
entrypoint: [""]
variables:
TF_ROOT: ${CI_PROJECT_DIR}/environments/prod
TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/prod
stages:
- validate
- plan
- apply
before_script:
- cd ${TF_ROOT}
- terraform init
validate:
stage: validate
script:
- terraform validate
- terraform fmt -check
plan:
stage: plan
script:
- terraform plan -out=plan.tfplan
artifacts:
paths:
- plan.tfplan
apply:
stage: apply
script:
- terraform apply plan.tfplan
dependencies:
- plan
only:
- main
when: manual

Jenkins Pipeline

// Jenkinsfile
pipeline {
agent any
environment {
TF_IN_AUTOMATION = 'true'
AWS_CREDENTIALS = credentials('aws-credentials')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Terraform Init') {
steps {
sh 'terraform init'
}
}
stage('Terraform Format') {
steps {
sh 'terraform fmt -check'
}
}
stage('Terraform Validate') {
steps {
sh 'terraform validate'
}
}
stage('Terraform Plan') {
steps {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'aws-credentials',
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
sh 'terraform plan -out=tfplan'
}
}
}
stage('Terraform Apply') {
when {
branch 'main'
}
steps {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'aws-credentials',
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
sh 'terraform apply -auto-approve tfplan'
}
}
}
}
post {
always {
cleanWs()
}
}
}

Automated Testing Setup

Test Structure

.
├── modules/
│ └── vpc/
│ ├── main.tf
│ └── test/
│ ├── vpc_test.go
│ └── integration_test.go
├── environments/
│ ├── dev/
│ │ └── main.tf
│ └── prod/
│ └── main.tf
└── test/
├── go.mod
├── go.sum
└── helper/
└── test_helper.go

Test Helper Functions

test/helper/test_helper.go
package helper
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func SetupTestEnvironment(t *testing.T, terraformDir string) *terraform.Options {
terraformOptions := &terraform.Options{
TerraformDir: terraformDir,
Vars: map[string]interface{}{
"environment": "test",
"region": "us-west-2",
},
NoColor: true,
}
terraform.Init(t, terraformOptions)
return terraformOptions
}
func CleanupTestEnvironment(t *testing.T, terraformOptions *terraform.Options) {
terraform.Destroy(t, terraformOptions)
}

Continuous Deployment Strategies

Blue-Green Deployment

modules/blue-green/main.tf
variable "environment" {
type = string
}
variable "color" {
type = string
}
resource "aws_autoscaling_group" "app" {
name = "${var.environment}-${var.color}"
launch_template {
id = aws_launch_template.app.id
}
target_group_arns = [aws_lb_target_group.app.arn]
tag {
key = "Environment"
value = var.environment
propagate_at_launch = true
}
tag {
key = "Color"
value = var.color
propagate_at_launch = true
}
}
resource "aws_lb_listener_rule" "app" {
listener_arn = aws_lb_listener.front_end.arn
action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
condition {
host_header {
values = ["${var.color}.${var.environment}.example.com"]
}
}
}

Canary Deployment

modules/canary/main.tf
resource "aws_lambda_alias" "app" {
name = "production"
function_name = aws_lambda_function.app.function_name
function_version = aws_lambda_function.app.version
routing_config {
additional_version_weights = {
"${aws_lambda_function.app_new.version}" = var.canary_weight
}
}
}
resource "aws_cloudwatch_metric_alarm" "errors" {
alarm_name = "lambda-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "2"
metric_name = "Errors"
namespace = "AWS/Lambda"
period = "300"
statistic = "Sum"
threshold = "1"
dimensions = {
FunctionName = aws_lambda_function.app_new.function_name
Resource = aws_lambda_function.app_new.function_name
}
alarm_actions = [aws_sns_topic.rollback.arn]
}

Monitoring and Alerting

CloudWatch Integration

resource "aws_cloudwatch_dashboard" "terraform" {
dashboard_name = "terraform-deployment"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
width = 12
height = 6
properties = {
metrics = [
["AWS/States", "ExecutionsSucceeded"],
["AWS/States", "ExecutionsFailed"]
]
period = 300
stat = "Sum"
region = "us-west-2"
title = "Terraform State Machine Executions"
}
}
]
})
}
resource "aws_cloudwatch_metric_alarm" "deployment_failure" {
alarm_name = "terraform-deployment-failure"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = "1"
metric_name = "ExecutionsFailed"
namespace = "AWS/States"
period = "300"
statistic = "Sum"
threshold = "0"
alarm_actions = [aws_sns_topic.alerts.arn]
}

Next Steps

In Part 8: Terraform Security and Best Practices, we’ll explore security considerations and best practices for Terraform deployments.

Additional Resources