You don’t need any special tool to automatically test your IaC code, you can use any programming language and unit testing framework you like.
You should test your Infrastructure as Code because it is code - that is probably obvious. Most likely, you want to do it automatically. Here I will share our approach to testing IaC and how it drove our technology decisions for the DevOpsBox platform (https://www.devopsbox.io/).
The Test Pyramid
Let’s start with some theory. There is an old concept of the test pyramid:
There are often different names on the diagram but the concept is:
- You should have a lot of fast robust tests, which are often unit tests. It is important they execute in milliseconds and do not depend on any external services.
- There should be a moderate number of integration tests. These tests often depend on external services, but are not testing the whole system. They are much slower and more fragile than unit tests, probably executed in seconds or minutes, and can sometimes fail e.g. because of the network problems.
- You should only have a few tests of your whole system. They are really slow and fragile. It is sometimes very hard to convince management and business to that, but this is the reality.
It is important to note that nothing will check how code works, as fast as the unit tests do. You should write them for yourself to check your code. However, there are some misconceptions about what the unit is and how to write good tests. In my opinion, you should always think about what you want to achieve with your code and test this behavior - sometimes you will test a single function/method, sometimes something bigger. You shouldn’t call external dependencies in your unit tests and should write your tests in a way that will not require you to change them after a refactoring. Ports and adapters architecture can help with that. If you have good unit tests for a part of your application, you can assume that it works well, like you often assume that some external program works (e.g. AWS CLI). Then, you don’t have to test every variant in your integration or system tests.
Infrastructure as Code tests examples
Often, when we talk about Infrastructure as Code tools, Terraform comes to our minds. It is great for maintaining your infrastructure state and talking to your cloud provider’s API, but you have to write code in HCL, which is not a real programming language. Is it bad? Sometimes yes, especially when it comes to writing unit tests. Let’s check how we can test the Terraform code and what we can use instead, and have real unit tests!
Our example code under test will create an S3 bucket maintaining naming conventions:
- Template of the bucket name will be
<company name>-<env name>-<app name>-<bucket purpose>
(e.g.acme-dev-orders-pictures
) if it does not exceed 63 characters (maximum for an s3 bucket) - If it does exceed 63 characters, it will be a hash of the name. We will use a substring of a sha256.
Prerequisites
You will need several tools:
- GO (tested with 1.15.3)
- Terraform (tested with 0.14.3)
- AWS CDK (tested with 1.71.0)
- Java JDK (tested with openjdk 11.0.9)
- NodeJS (required by AWS CDK, tested with v12.18.3)
- Docker (tested with 18.09.5)
- cdklocal (https://github.com/localstack/aws-cdk-local, tested with 1.65.2)
An AWS account with proper credentials is also required. The code will probably work with other versions too.
Terratest
We will test this Terraform code https://github.com/devopsbox-io/example-iac-test/blob/master/terraform/s3/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
provider "aws" {
region = var.aws_region
}
locals {
requested_bucket_name = "${var.company_name}-${var.env_name}-${var.app_name}-${var.bucket_purpose}"
bucket_name = length(local.requested_bucket_name) > 63 ? substr(sha256(local.requested_bucket_name), 0, 63) : local.requested_bucket_name
}
resource "aws_s3_bucket" "bucket" {
bucket = local.bucket_name
}
and variables https://github.com/devopsbox-io/example-iac-test/blob/master/terraform/s3/variables.tf
variable "aws_region" {
type = string
}
variable "company_name" {
type = string
}
variable "env_name" {
type = string
}
variable "app_name" {
type = string
}
variable "bucket_purpose" {
type = string
}
Nothing special here, just the implementation of our example in Terraform.
Tests in Terratest are quite easy to write https://github.com/devopsbox-io/example-iac-test/blob/master/test/s3_module_test.go
func TestS3BucketCreated(t *testing.T) {
t.Parallel()
envName := strings.ToLower(random.UniqueId())
awsRegion := "eu-west-1"
tests := map[string]struct {
terraformVariables map[string]interface{}
expectedBucketName string
}{
"short name": {
terraformVariables: map[string]interface{}{
"aws_region": awsRegion,
"company_name": "acme",
"env_name": envName,
"app_name": "orders",
"bucket_purpose": "pictures",
},
expectedBucketName: "acme-" + envName + "-orders-pictures",
},
"long name": {
terraformVariables: map[string]interface{}{
"aws_region": awsRegion,
"company_name": "acme",
"env_name": envName,
"app_name": "orders",
"bucket_purpose": "pictures12345678901234567890123456789012345678901234567890",
},
expectedBucketName: sha256String("acme-" + envName + "-orders-pictures12345678901234567890123456789012345678901234567890")[:63],
},
}
for name, testCase := range tests {
// capture range variables
name := name
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
terraformModuleDir, err := files.CopyTerraformFolderToTemp("../terraform/s3", "terratest-")
if err != nil {
t.Fatalf("Error while creating temp dir %v", err)
}
defer os.RemoveAll(terraformModuleDir)
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: terraformModuleDir,
Vars: testCase.terraformVariables,
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
aws.AssertS3BucketExists(t, awsRegion, testCase.expectedBucketName)
})
}
}
func sha256String(str string) string {
sha256Bytes := sha256.Sum256([]byte(str))
return hex.EncodeToString(sha256Bytes[:])
}
I have used a pattern called data-driven (or table-driven) tests (read more here https://dave.cheney.net/2019/05/07/prefer-table-driven-tests). Tests are executed in parallel, what is quite nice. To do that you have to copy your module using CopyTerraformFolderToTemp
and reassign range variables (two lines after the // capture range variables
comment).
Execute this code with:
cd test
go test
There are a few problems here:
- These are not unit tests. They need to create your infrastructure, so they are slow and fragile.
- Look at this simple algorithm in HCL and think of something a little bit more complicated - it can be really hard to implement, read, and maintain.
- You have to use Golang - it is no longer an issue for us, but it used to be.
You can’t use Terratest assertions with LocalStack and there is an open pull request https://github.com/gruntwork-io/terratest/pull/495 solving this issue. Of course, you could write your own assertions and we will do it later in a different language, so let’s skip this now and move on.
Test Terraform with custom “miniframework” based on Spock
We didn’t want to write tests in Golang, so we decided to check if it is possible to use some other language. One of my favorite testing frameworks is Spock - it is JVM based and you write your tests in Groovy. We decided to check it and it turns out that it works very well! You “only” have to write some glue code, what is not that hard to do.
The Terraform code is almost the same, but with LocalStack support, we had to change AWS provider configuration https://github.com/devopsbox-io/example-iac-test/blob/master/terraform/s3/main.tf
provider "aws" {
region = var.aws_region
access_key = var.use_localstack ? "fake_access_key" : null
s3_force_path_style = var.use_localstack
secret_key = var.use_localstack ? "fake_secret_key" : null
skip_credentials_validation = var.use_localstack
skip_metadata_api_check = var.use_localstack
skip_requesting_account_id = var.use_localstack
dynamic "endpoints" {
for_each = var.use_localstack ? [1] : []
content {
s3 = "http://localhost:4566"
}
}
}
and add a single additional variable:
variable "use_localstack" {
type = bool
default = false
}
The test looks like this https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/s3/S3TerraformModuleTest.groovy
class S3TerraformModuleTest extends TerraformIntegrationTest {
@Shared
S3 s3
def setupSpec() {
s3 = new S3(sdkClients)
}
@Unroll
def "should create s3 bucket #testCase"() {
given:
def terraformVariables = new S3TerraformModuleVariables(
useLocalstack: localstack.enabled,
awsRegion: awsRegion(),
companyName: "acme",
envName: environmentName(),
appName: "orders",
bucketPurpose: bucketPurpose,
)
when:
deployTerraformModule("terraform/s3", terraformVariables)
then:
s3.checkBucketExists(expectedBucketName)
cleanup:
destroyTerraformModule("terraform/s3", terraformVariables)
where:
testCase | bucketPurpose | expectedBucketName
"short name" | "pictures" | "acme-" + environmentName() + "-orders-pictures"
"long name" | "pictures12345678901234567890123456789012345678901234567890" | DigestUtils.sha256Hex("acme-" + environmentName() + "-orders-pictures12345678901234567890123456789012345678901234567890").substring(0, 63)
}
class S3TerraformModuleVariables extends TerraformVariables {
boolean useLocalstack
String awsRegion
String companyName
String envName
String appName
String bucketPurpose
}
}
The code is quite nice. Notice S3TerraformModuleVariables class - not that bad way to pass input variables to our Terraform stack. One of the problems is the lack of parallelism support (will be supported in Spock 2.0 http://spockframework.org/spock/docs/2.0-M4/parallel_execution.html#parallel-execution). We already have LocalStack support here - long-running tests should be faster because you don’t have to wait for the real infrastructure. However, I think that you should also run your tests with the real cloud - sometimes LocalStack can behave differently, or maybe does not support some cloud resource you need.
To execute this code run:
./gradlew integTest --tests *S3TerraformModuleTest
or with LocalStack:
./gradlew integTest --tests *S3TerraformModuleTest -Dlocalstack.enabled=true
Are there any problems here? Yes:
- Still no unit tests
- Still not a real programming language
The “miniframework” glue code
Have I mentioned the glue code? There is some, but really not that much. I will only list all the files, describe them but not paste the whole code here:
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/ProcessRunner.java responsible for executing external processes
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/Localstack.groovy supports execution in LocalStack
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/aws/SdkClientProvider.java creates AWS clients to use in assertions
-
https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/aws/S3.java contains S3 checks - here only
checkBucketExists
, use this code in your test’s assertions, add more checks when necessary -
https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/terraform/Terraform.java Terraform wrapper - uses the
ProcessRunner
class to execute an external Terraform process - https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/terraform/TerraformVariables.groovy base class for the Terraform variables
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/terraform/TerraformVariablesMarshaller.groovy creates JSON files from the Terraform variables objects
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/terraform/TerraformIntegrationTest.groovy base class for all your Terraform tests
- https://github.com/devopsbox-io/example-iac-test/blob/master/build.gradle the Gradle configuration prepared to run the tests
It is a mix of Groovy and Java. You can copy this code, use it in your project or maybe even rewrite it into another language. If you think we should create a real framework and provide a jar - please let me know, we will do our best.
As you can see - you don’t need any special tool to test your Infrastructure as Code, only your favorite language, unit testing framework, and a few hours to write some glue code.
Test AWS CDK with custom “miniframework” based on Spock
We really wanted to write our code in a general-purpose programming language and had the ability to unit test it. After some research, we found two frameworks:
- AWS CDK
- Pulumi
Only AWS CDK supported Java, so it was our choice, although I have to write a few words about Pulumi: it is really cool, I used it in one of my projects using TypeScript and I was impressed. Coming back to AWS CDK - when we started to write the code, CDK was in its early stages and AWS changed its API very often, but that is a completely different story…
Here is the AWS CDK code for which we will write tests https://github.com/devopsbox-io/example-iac-test/blob/master/src/main/java/io/devopsbox/infrastructure/test/s3/S3Construct.java
public class S3Construct extends Construct {
public S3Construct(Construct scope, String id, S3ConstructProps props) {
super(scope, id);
String bucketName = props.getBucketName();
new Bucket(this, bucketName, BucketProps.builder()
.removalPolicy(RemovalPolicy.DESTROY)
.bucketName(bucketName)
.build());
}
}
we moved our bucket naming logic to another class https://github.com/devopsbox-io/example-iac-test/blob/master/src/main/java/io/devopsbox/infrastructure/test/s3/S3ConstructProps.java
public class S3ConstructProps extends ConstructProps {
public static final int BUCKET_NAME_MAX_LENGTH = 63;
private final String bucketPurpose;
public S3ConstructProps(String companyName, String envName, String appName, String bucketPurpose) {
super(companyName, envName, appName);
this.bucketPurpose = bucketPurpose;
}
public String getBucketPurpose() {
return bucketPurpose;
}
public String getBucketName() {
String bucketName = getCompanyName() + "-" + getEnvName() + "-" + getAppName() + "-" + getBucketPurpose();
if (bucketName.length() > BUCKET_NAME_MAX_LENGTH) {
bucketName = DigestUtils.sha256Hex(bucketName).substring(0, BUCKET_NAME_MAX_LENGTH);
}
return bucketName;
}
}
There is also a base class for all construct property classes with a set of common properties https://github.com/devopsbox-io/example-iac-test/blob/master/src/main/java/io/devopsbox/infrastructure/test/ConstructProps.java
public class ConstructProps implements Serializable {
private final String companyName;
private final String envName;
private final String appName;
public ConstructProps(String companyName, String envName, String appName) {
this.companyName = companyName;
this.envName = envName;
this.appName = appName;
}
public String getCompanyName() {
return companyName;
}
public String getEnvName() {
return envName;
}
public String getAppName() {
return appName;
}
}
And two standard CDK classes:
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/main/java/io/devopsbox/infrastructure/test/IacTestApp.java this is the main class, our entry point
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/main/java/io/devopsbox/infrastructure/test/IacTestStack.java CDK stack, we create our construct here
The test is similar to the one written for Terraform https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/s3/S3CdkConstructTest.groovy
class S3CdkConstructTest extends CdkIntegrationTest {
@Shared
S3 s3
def setupSpec() {
s3 = new S3(sdkClients)
}
def "should create s3 bucket"() {
given:
def stackId = "S3BucketConstructTest" + environmentName()
def constructProps = new S3ConstructProps(
"acme",
environmentName(),
"orders",
"pictures"
)
when:
deployCdkConstruct(stackId, S3Construct, constructProps)
then:
s3.checkBucketExists("acme-" + environmentName() + "-orders-pictures")
cleanup:
destroyCdkConstruct(stackId, S3Construct, constructProps)
}
}
To execute this code run:
./gradlew integTest --tests *S3CdkConstructTest
or with LocalStack:
./gradlew integTest --tests *S3CdkConstructTest -Dlocalstack.enabled=true
We are not testing all the cases here, just a single integration test, because we can finally write unit tests! The code looks like this https://github.com/devopsbox-io/example-iac-test/blob/master/src/test/groovy/io/devopsbox/infrastructure/test/s3/S3ConstructPropsTest.groovy
class S3ConstructPropsTest extends Specification {
@Unroll
def "should return s3 bucket #testCase"() {
given:
def props = new S3ConstructProps(
"acme",
"dev",
"orders",
bucketPurpose,
)
when:
def bucketName = props.bucketName
then:
bucketName == expectedBucketName
where:
testCase | bucketPurpose | expectedBucketName
"short name" | "pictures" | "acme-dev-orders-pictures"
"long name" | "pictures12345678901234567890123456789012345678901234567890" | DigestUtils.sha256Hex("acme-dev-orders-pictures12345678901234567890123456789012345678901234567890").substring(0, 63)
}
}
Just run it with ./gradlew test
and it completes in milliseconds!
Finally, we can write the code in a real programming language of our choice and create unit tests. Is it perfect? Certainly not. Our integration tests run in a different process, so there are some drawbacks. There is support for running in the same process in Pulumi (https://github.com/pulumi/pulumi/issues/3901) but not in AWS CDK yet (https://github.com/aws/aws-cdk/issues/601). We can also improve our “miniframework” and run tests using a chosen IAM role - we do that in DevOpsBox already, but it is not included here for the sake of simplicity.
More “miniframework” glue code…
We have to add a few files to our “miniframework” to support AWS CDK:
-
https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/cdk/Cdk.java uses the
ProcessRunner
class to execute an external AWS CDK process; note the debugging support - when you set theCDK_DEBUG_ENABLED
environment variable totrue
, it will start gradle with “ --debug-jvm” flag and wait for the remote debugger to connect - https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/cdk/PropsFileSerializer.groovy responsible for properties file serialization and deserialization
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/cdk/CdkIntegrationTestStack.groovy CDK stack class used only in tests
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/cdk/CdkIntegrationTestMain.groovy main CDK class (entry point) used only in tests
- https://github.com/devopsbox-io/example-iac-test/blob/master/src/integTest/groovy/io/devopsbox/infrastructure/test/common/cdk/CdkIntegrationTest.groovy base class for all your AWS CDK tests
-
https://github.com/devopsbox-io/example-iac-test/blob/master/build.gradle modified Gradle configuration,
startCdkTest
task added
A little bit less than for Terraform, because we can reuse some classes written before.
Conclusion
The ability to write unit tests was one of the key factors behind choosing AWS CDK as our Infrastructure as Code tool, and after choosing it we found it very convenient to write IaC code in a programming language of our choice, have good code structure, do refactorings, use design patterns, have a great IDE support, use external libraries and much more. It’s great that nowadays we can write IaC in a general-purpose programming language and it is still declarative.
I hope that the terraform-cdk project (https://github.com/hashicorp/terraform-cdk) will be usable soon and maintained in the future. Then, we will be able to have a cake and eat it too!
For more details about the DevOpsBox platform please visit https://www.devopsbox.io/
Top comments (0)