Skip to main content

Component Testing

This documentation will guide you through our comprehensive strategy for testing Terraform components, provide step-by-step instructions and practical examples to help you validate your component configurations effectively. Whether you’re setting up initial tests, adding dependencies, or verifying output assertions, you’ll find the resources you need to ensure robust and reliable deployments.

Context

Our component testing strategy is a direct outcome of our migration to a dedicated GitHub Organization for components. This separation allows each component to live in its own repository, enabling independent versioning and testing. It not only improves the reliability of each component but also empowers the community to contribute via pull requests confidently. With integrated testing for every PR, we can ensure high quality and build trust in each contribution.

For more information on building and maintaining components, please refer to our Component Development Guide, which provides detailed insights into best practices, design principles, and the overall process of component development.

Prerequisites

1 Install Terraform / Tofu

Ensure you have Terraform or OpenTofu installed on your machine.

2 Install Atmos

Atmos is a tool for managing Terraform environments.

3 Install Golang

Go is a programming language that you'll need to run the tests. Download and install Go from the official Go website. Make sure to set up your Go environment correctly by following the Getting Started with Go guide.

4 Authenticate on AWS

Ensure you have the necessary AWS credentials configured on your machine. You can do this by setting up the AWS CLI and running aws configure, where you'll input your AWS Access Key, Secret Key, region, and output format. Refer to the AWS CLI documentation for more details.

Test Framework

Component testing framework assumes that each component's repo structure follows the convention when all component terraform source code would be stored in src directory and everything related to tests will be placed in test directory. Tests consists of two coupled parts - atmos configuration fixtures and tests written on Go code. Repo structure should be simular to this one:

component-root/
├── src/ # Component source directory
│ └── main.tf
└── test/ # Tests directory
├── fixtures/ # Atmos configurations
├── component_test.go # Tests
├── go.mod
└── go.sum

Atmos configuration fixtures

Atmos configuration fixtures provides minimal settings to deploy the component and it's dependencies on test account during test run.

The difference with a regular atmos configuration are:

  1. All components deployed on one stack default-test in one us-east-2 region.
  2. Use single aws account for all test resources. If component assumes the cross region or cross account interaction, the configuration still deploys it to the same actual aws account.
  3. Mock account-map component to skip role assuming and always use current AWS credentials provided with environment variables
  4. Configure teraform state files storage to local directory at a path provided by test framework with environment variable COMPONENT_HELPER_STATE_DIR

This configuration is common for all components and could be copied from template repo.

Fixtures directory structure looks like

fixtures/
├── stacks/
| ├── catalog/
| | ├── usecase/
| | | ├── basic.yaml
| | | └── disabled.yaml
| | └── account-map.yaml
│ └── orgs/default/test/
| ├── _defaults.yaml
| └── tests.yaml
├── atmos.yaml
└── vendor.yaml

For most components, avoid any changes to these files

  1. atmos.yaml - shared atmos config common for all test cases
  2. stacks/catalog/account-map.yaml - Mock account-map configuration makes any environment/stack/tenant to be backed with the signle AWS test account
  3. stacks/orgs/default/test/_defaults.yaml - Configure terraform state backend to local directory and define shared variables for default-test

This files and directories contains custom configurations specific for a testing component:

  1. vendor.yaml - Vendor configuration for all component dependencies
  2. stacks/catalog/ - Store all dependencies configuration files in the dir
  3. stacks/catalog/uscases - Store configuration of the testing component's usescases
  4. stacks/catalog/uscases/basic.yaml - Predefined file for basic configuration of the testing component's usescase
  5. stacks/catalog/uscases/disabled.yaml - Predefined file for disabled configuration usescase (when variable enabled: false)
  6. stacks/orgs/default/test/tests.yaml - Include all dependencies and uscases configurations to deploy them for default-test stack

Tests (Golang)

Component tests are writen on go lang as this general purpose language is standard defacto for cloud compute engineering Under the hood tests uses several libraries with helper functions

  1. github.com/cloudposse/test-helpers/atmos/component-helper - Component testing framework provides
  2. github.com/cloudposse/test-helpers/atmos - Atmos API
  3. github.com/cloudposse/test-helpers/aws - Test helpers interact with AWS
  4. github.com/cloudposse/terratest/aws - Test helpers provided by GruntWork
  5. github.com/aws/aws-sdk-go-v2 - AWS API

You can specify any additional dependency libraries by running go get {library name}.

Test framework extends github.com/stretchr/testify/suite to organize test suites. Regular test file structure follow this example:

test/component_test.go
package test

import (
"context"
"testing"
"fmt"
"strings"
helper "github.com/cloudposse/test-helpers/pkg/atmos/component-helper"
awsHelper "github.com/cloudposse/test-helpers/pkg/aws"
"github.com/cloudposse/test-helpers/pkg/atmos"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type ComponentSuite struct {
helper.TestSuite
}

// Functions Test prefix are entrypoint for `go test`
func TestRunSuite(t *testing.T) {
# Define test suite instance
suite := new(ComponentSuite)

// Add dependency to the dependencies queue
suite.AddDependency(t, "vpc", "default-test", nil)

// Run test suite
helper.Run(t, suite)
}

// Test suite methods prefixed with `Test` are tests

// Test basic usecase
func (s *ComponentSuite) TestBasic() {
const component = "example/basic"
const stack = "default-test"
const awsRegion = "us-east-2"

// Destroy test component
defer s.DestroyAtmosComponent(s.T(), component, stack, nil)
// Deploy test component
options, _ := s.DeployAtmosComponent(s.T(), component, stack, nil)
assert.NotNil(s.T(), options)

// Get test component output
id := atmos.Output(s.T(), options, "eks_cluster_id")
assert.True(s.T(), strings.HasPrefix(id, "eg-default-ue2-test-"))

// Test component drift
s.DriftTest(component, stack, nil)
}

// Test disabled usecase
func (s *ComponentSuite) TestEnabledFlag() {
const component = "example/disabled"
const stack = "default-test"

// Verify no resources created when `enabled: false`
s.VerifyEnabledFlag(component, stack, nil)
}

CLI Flags cheatsheet

A test suite run consists of the following phases all of which can be controlled by passing flags:

PhaseDescriptionSkip flag
SetupSetup test suite and deploy dependencies--skip-setup
TestDeploy the component--only-deploy-dependencies
TeardownDestroy all dependencies--skip-teardown

This is possible to enable/disable steps on each phase more precisly

PhaseDescriptionSkip flag
SetupVendor dependencies--skip-vendor
SetupDeploy component dependencies--skip-deploy-dependencies
TestDeploy the component--skip-deploy-component
TestPerform assertions
TestDestroy the deployed component (on defer)--skip-destroy-component
TeardownDestroy all dependencies--skip-destroy-dependencies

Here is the usefull combination of flags.

CommandDescription
go test -timeout 1h --only-deploy-dependencies --skip-destroy-dependenciesDeploy dependencies only. Usefull when you works only on dependencies before any actual tests defined
go test -timeout 1h --skip-setup --skip-teardown --skip-destroy-componentDeploy testing component. Use previously deployed dependencies. Do not destroy anything. Usefull when you are working on deploing usecase
go test -timeout 1h --skip-setup --skip-teardown --skip-deploy-component --skip-destroy-componentDo not deploy or destroy anything. Usefull when you are working on tests asserts
go test -timeout 1h --skip-setup --skip-deploy-componentDestroy component and it's dependencies. Usefull when your tests are done to clean up all resourecs

Read more about the test helpers framework

Write test

1 Copy the test scaffold files

If you missed the test scaffold files, copy the contents from this GitHub repository into your component repository. This will provide you with the necessary structure and example tests to get started. The repo structure should looks like the following:

├── src/
│ └── main.tf
└── test/
├── fixtures/
│   ├── stacks/
│   | ├── catalog/
│   | | ├── usecase/
│   | | | ├── basic.yaml
│   | | | └── disabled.yaml
│   | | └── account-map.yaml
│ │ └── orgs/default/test/
│   | ├── _defaults.yaml
│   | └── tests.yaml
│ ├── atmos.yaml
│ └── vendor.yaml
├── component_test.go
├── go.mod
└── go.sum

2 Run Initial Tests

Navigate to the test directory and run tests in your terminal by running

cd test
go test -v -timeout 1h --only-deploy-dependencies

3 Add Dependency

Identify any additional dependencies your component require. Skip this step if the component doesn't have any dependencies.

Add dependency to the vendor file

test/fixtures/vendor.yaml
apiVersion: atmos/v1
kind: AtmosVendorConfig
metadata:
name: fixtures
description: Atmos vendoring manifest
spec:
sources:
- component: "account-map"
source: github.com/cloudposse/terraform-aws-components.git//modules/account-map?ref={{.Version}}
version: 1.520.0
targets:
- "components/terraform/account-map"
included_paths:
- "**/*.tf"
- "**/*.md"
- "**/*.tftmpl"
- "**/modules/**"
excluded_paths: []

# Example of a dependency from vpc component
- component: "vpc"
source: github.com/cloudposse-terraform-components/aws-vpc.git//src?ref={{.Version}}
version: v1.536.0
# Specify the path to the component directory
targets:
- "components/terraform/vpc"
included_paths:
- "**/*.tf"
- "**/*.md"
- "**/*.tftmpl"
- "**/modules/**"
excluded_paths: []
# Example of a dependency from vpc component

Add atmos component configurations

test/fixtures/stacks/catalog/vpc.yaml
components:
terraform:
vpc:
metadata:
component: vpc
vars:
name: "vpc"
availability_zones:
- "b"
- "c"
public_subnets_enabled: true
max_nats: 1
# Private subnets do not need internet access
nat_gateway_enabled: false
nat_instance_enabled: false
subnet_type_tag_key: "eg.cptest.co/subnet/type"
max_subnet_count: 3
vpc_flow_logs_enabled: false
ipv4_primary_cidr_block: "172.16.0.0/16"

Import the dependent component for default-test stack

test/fixtures/stacks/orgs/default/test/tests.yaml
import:
- orgs/default/test/_defaults
# Import the dependent component
- catalog/vpc

Add the dependent component to test suite with Go code By default, the test suite will add a unique random value to the attributes terraform variable. This is to avoid resource naming collisions with other tests that are using the same component. But in some cases, you may need to pass unique value to specific input for the component. Check out the advanced example for the most common use-case with the dns-delegated domain name.

test/component_test.go
  package test

import (
"testing"

helper "github.com/cloudposse/test-helpers/pkg/atmos/component-helper"
)

type ComponentSuite struct {
helper.TestSuite
}

func (s *ComponentSuite) TestBasic() {
// Add empty test
// Suite setup would not be executed without at least one test
}

func TestRunSuite(t *testing.T) {
suite := new(ComponentSuite)

// Deploy the dependent vpc component
suite.AddDependency(t, "vpc", "default-test", nil)

helper.Run(t, suite)
}

Deploy dependencies

go test -v -timeout 1h --only-deploy-dependencies --skip-destroy-dependencies

4 Add Test Usecase

Add atmos configuration for the component usecase

test/fixtures/stacks/catalog/usecase/basic.yaml
components:
terraform:
# You can replace example-component with your component name
example-component/basic:
metadata:
# Component name dir should be always `target`
component: target
vars:
enabled: true
# Add other inputs that are required for the use case

Import the usecase for default-test stack

test/fixtures/stacks/orgs/default/test/tests.yaml
import:
- orgs/default/test/_defaults
- catalog/vpc
# Import the usecase
- catalog/usecase/basic

Write test

test/component_test.go
package test

import (
"testing"

helper "github.com/cloudposse/test-helpers/pkg/atmos/component-helper"
)

type ComponentSuite struct {
helper.TestSuite
}

func TestRunSuite(t *testing.T) {
suite := new(ComponentSuite)

suite.AddDependency(t, "vpc", "default-test", nil)

helper.Run(t, suite)
}

func (s *ComponentSuite) TestBasic() {
const component = "example-component/basic"
const stack = "default-test"
const awsRegion = "us-east-2"

// How to read outputs from the dependent component
// vpcOptions, err := s.GetAtmosOptions("vpc", stack, nil)
// id := atmos.Output(s.T(), vpcOptions, "id")

inputs := map[string]interface{}{
// Add other inputs that are required for the use case
}

defer s.DestroyAtmosComponent(s.T(), component, stack, &inputs)
options, _ := s.DeployAtmosComponent(s.T(), component, stack, &inputs)
assert.NotNil(s.T(), options)
}

Deploy test component

go test -v -timeout 1h --skip-deploy-dependencies --skip-destroy-dependencies --skip-destroy-component --skip-teardown

5 Add Asserts

Within your test, include assertions to validate the expected outcomes. Use Go's testing package to assert conditions that must be true for the test to pass. This will help ensure that your component behaves as expected.

test/component_test.go
package test

import (
"testing"

"github.com/cloudposse/test-helpers/pkg/atmos"
helper "github.com/cloudposse/test-helpers/pkg/atmos/component-helper"
"github.com/stretchr/testify/assert"
)

type ComponentSuite struct {
helper.TestSuite
}

func TestRunSuite(t *testing.T) {
suite := new(ComponentSuite)

suite.AddDependency(t, "vpc", "default-test", nil)

helper.Run(t, suite)
}

func (s *ComponentSuite) TestBasic() {
const component = "example-component/basic"
const stack = "default-test"
const awsRegion = "us-east-2"

// How to read outputs from the dependent component
// vpcOptions, err := s.GetAtmosOptions("vpc", stack, nil)
// id := atmos.Output(s.T(), vpcOptions, "id")

inputs := map[string]interface{}{
// Add other inputs that are required for the use case
}

defer s.DestroyAtmosComponent(s.T(), component, stack, &inputs)
options, _ := s.DeployAtmosComponent(s.T(), component, stack, &inputs)
assert.NotNil(s.T(), options)

// How to read string output from the component
output1 := atmos.Output(s.T(), options, "output_name_1")
assert.Equal(s.T(), "expected_value_1", output1)

// How to read list of strings output from the component
output2 := atmos.OutputList(s.T(), options, "output_name_2")
assert.Equal(s.T(), "expected_value_2", output2[0])
assert.ElementsMatch(s.T(), ["expected_value_2"], output2)

// How to read map of objects output from the component
output3 := atmos.OutputMapOfObjects(s.T(), options, "output_name_3")
assert.Equal(s.T(), "expected_value_3", output3["key"])

// How to read struct output from the component
type outputStruct struct {
keyName string `json:"key"`
}
output4 := outputStruct{}
atmos.OutputStruct(s.T(), options, "output_name_4", &output4)
assert.Equal(s.T(), "expected_value_4", output4["keyName"])
}

Run test

go test -v -timeout 1h --skip-deploy-dependencies --skip-destroy-dependencies --skip-destroy-component --skip-teardown

6 Add drifting test

The drifting test ensures that the component is not change any resources on rerun with the same inputs.

Add a "drifting test" check

test/component_test.go
func (s *ComponentSuite) TestBasic() {
const component = "example-component/basic"
const stack = "default-test"
const awsRegion = "us-east-2"

inputs := map[string]interface{}{}

defer s.DestroyAtmosComponent(s.T(), component, stack, &inputs)
options, _ := s.DeployAtmosComponent(s.T(), component, stack, &inputs)
assert.NotNil(s.T(), options)

// ...

// Just add this line to the check for drift
s.DriftTest(component, stack, &inputs)
}

Run test

go test -v -timeout 1h --skip-deploy-dependencies --skip-destroy-dependencies --skip-destroy-component --skip-teardown

7 Test "disabled" Use-case

All components should avoid creating any resources if the enabled input is set to false.

Add atmos configuration for the component usecase

test/fixtures/stacks/catalog/usecase/disabled.yaml
components:
terraform:
# You can replace example-component with your component name
example-component/disabled:
metadata:
component: target
vars:
# Disable the component
enabled: false

Import the usecase for default-test stack

test/fixtures/stacks/orgs/default/test/tests.yaml
import:
- orgs/default/test/_defaults
- catalog/vpc
- catalog/usecase/basic
# Import the "disabled" usecase
- catalog/usecase/disabled

Add a "disabled" usecase test

test/component_test.go
// ...

func (s *ComponentSuite) TestEnabledFlag() {
const component = "example-component/disabled"
const stack = "default-test"
s.VerifyEnabledFlag(component, stack, nil)
}

Run test

go test -v -timeout 1h --skip-deploy-dependencies --skip-destroy-dependencies --skip-destroy-component --skip-teardown

8 Tear down resources

Tear down the test environment

go test -v -timeout 1h --skip-deploy-dependencies