Introduction

In Terraform 1.6, the terraform test command was introduced. Terraform Test allows you to write unit or integration tests to either test that your Terraform code produces the expected output, or you can provide input data to run tests against your Terraform code.

From version 1.7, Terraform introduced the ability to use mock providers. This allows you to simulate the behaviour of actual Terraform providers within the testing environment by mocking providers, resources, and data sources.

In this article I'm going to provide examples of some practically useful unit tests to check that your code is producing the expected output. By setting command = plan in the run blocks, we are effectively running a terraform plan command, the terraform equivalent of Unit Testing.

Situational Example

Lets say you execute against the Dev environment from command line and it's easy to see the status of that by running a plan or apply command and seeing the output. But higher environments run from pipelines on different branches and you don't have the same visibility. This is the example I'll use for these tests.

Root module code

In a previous series of articles I covered User Friendly Terraform, Flattening data in Terraform, and then I wrapped it all up in Advanced terraform Wrap-Up. I'm going to re-use that code and add Terraform Tests to it.

I've just added a test directory to the root of the module and we'll create a couple of files in there to test the code.

  • ec2_values_mapped.tftest.hcl
  • ami_validation.tftest.hcl

Terraform Test

First, let's take a look at the ec2_values_mapped.tftest.hcl file. In these tests I'm going to do the following:

  • Define the equivalent of var.environment as stage so that the tests in this file will run against the Staging environment.
  • Set the command to plan in each run block so that the tests will run against the terraform plan output, there will be no resources created as part of this test.
  • Test that the user has defined some necessary values in the EC2 values map.
  • Check that the VPC value of an EC2 matches one that is defined in local.vpc[var.environment].
  • Check that the region of the EC2 matches the defined region in the matching VPC.
variables {
  environment = "stage"
}

# Run block to validate the Staging environment
run "validate_ec2_map_stage" {
  command = plan

  # Check EC2 object has a region, VPC, and count
  assert {
    condition = alltrue([for _, ec2 in local.ec2_map : lookup(ec2, "region", null) != null])
    error_message = "All EC2 instances must have a defined region in the Staging environment."
  }
  assert {
    condition = alltrue([for _, ec2 in local.ec2_map : lookup(ec2, "vpc", null) != null])
    error_message = "All EC2 instances must have a defined VPC in the Staging environment."
  }
  assert {
    condition = alltrue([for _, ec2 in local.ec2_map : lookup(ec2, "count", null) != null])
    error_message = "All EC2 instances must have a defined count in the Staging environment."
  }
}

run "validate_ec2_vpc_stage" {
  command = plan

  # Test vpc value of an EC2 is being defined in code
  assert {
    condition = alltrue([
      for _, ec2 in local.ec2_map :
      contains(
        keys(local.vpc[var.environment]),
        lookup(ec2, "vpc", "")
      )
    ])
    error_message = "VPC not found for the specified region in the Staging environment."
  }

  # Test that each EC2 instance's region matches the expected region from the VPC list
  assert {
    condition = alltrue([
      for _, ec2 in local.ec2_map :
      # Check if the EC2 instance's region matches the first item in the corresponding VPC's value list
      lookup(ec2, "region", "") == local.vpc[var.environment][keys(local.vpc[var.environment])[0]][0]
    ])
    error_message = "Region mismatch between EC2 instances and VPC in the Staging environment."
  }
}

In the first run block, we're just looking at local.ec2_map which is already filtered by environment in the flattening and mapping code, and we're checking that some values exist, region, vpc, and count.

In the 2nd run block, the first test is checking that the vpc value of the EC2 object is defined in local.vpc[var.environment], and the second test checks that the region of the EC2 instance matches the first item in the corresponding VPC.

Now, from the root of your terraform project, when you run a terraform test command, you should see the following output:

$ terraform test
tests/ec2_values_mapped.tftest.hcl... in progress
  run "validate_ec2_map_stage"... pass
  run "validate_ec2_vpc_stage"... pass
tests/ec2_values_mapped.tftest.hcl... tearing down
tests/ec2_values_mapped.tftest.hcl... pass

Success! 2 passed, 0 failed.

If I deliberately break the input code by removing the count, you will see output the same as when you would run a terraform plan command, which would allow you to fix the issue prior to pushing the code.

$ terraform test
tests/ec2_values_mapped.tftest.hcl... in progress
  run "validate_ec2_map_stage"... fail
╷
│ Error: Unsupported attribute
│ 
│   on flatten_ec2.tf line 4, in locals:
│    4:       for i in range(v.count) : merge(
│ 
│ This object does not have an attribute named "count".
╵
  run "validate_ec2_vpc_stage"... skip
tests/ec2_values_mapped.tftest.hcl... tearing down
tests/ec2_values_mapped.tftest.hcl... fail

Failure! 0 passed, 1 failed, 1 skipped.

Defining Variables within a run block

In this next example we're going to move the variables block into the run block. In this way we can test multiple environments in the same file.

In these tests we're going to do the following:

  • Each run block is still using the plan command.
  • Within each run block we're going to define the environment variable to either stage or prod.
  • Check, in each environment, that the EC2 instances without app in the key have an AMI defined.
# Run block to validate the Staging environment
run "validate_ami_staging" {
  command = plan

  variables {
    environment = "stage"
  }

  # Assertion for Staging: EC2 instances without 'app' in the key must have an AMI
  assert {
    condition = length([
      for key, ec2_data in local.ec2[var.environment] :
      key if !strcontains(key, "app") && lookup(ec2_data, "ami", null) != null
    ]) == length([
      for key, ec2_data in local.ec2[var.environment] :
      key if !strcontains(key, "app")
    ])
    error_message = "AMIs must be defined for EC2 instances without 'app' in their key in the Staging environment."
  }
}

# Run block to validate the Prod environment
run "validate_ami_prod" {
  command = plan

  variables {
    environment = "prod"
  }

  # Assertion for Prod: EC2 instances without 'app' in the key must have an AMI
  assert {
    condition = length([
      for key, ec2_data in local.ec2[var.environment] :
      key if !strcontains(key, "app") && lookup(ec2_data, "ami", null) != null
    ]) == length([
      for key, ec2_data in local.ec2[var.environment] :
      key if !strcontains(key, "app")
    ])
    error_message = "AMIs must be defined for EC2 instances without 'app' in their key in the Prod environment."
  }
}

In the root module, we have example-app and example-router in the ec2 object. We're saying, it's fine not to define the AMI for example-app because the EC2 code defaults to an Ubuntu image. But for example-router, a situation that would likely require the AMI of an appliance, we're saying that the AMI must be defined.

Now, when we have correctly defined AMIs in both Staging and Prod, if you run a terraform test command, you should see the following output:

$ terraform test
tests/ami_validation.tftest.hcl... in progress
  run "validate_ami_staging"... pass
  run "validate_ami_prod"... pass
tests/ami_validation.tftest.hcl... tearing down
tests/ami_validation.tftest.hcl... pass
tests/ec2_values_mapped.tftest.hcl... in progress
  run "validate_ec2_map_stage"... pass
  run "validate_ec2_vpc_stage"... pass
tests/ec2_values_mapped.tftest.hcl... tearing down
tests/ec2_values_mapped.tftest.hcl... pass

Success! 4 passed, 0 failed.

If I remove the AMI value from the Prod environment, Note in the following output that the underlying terraform plan command won't fail, so the other tests will still be executed, but we do see an error from the Prod validation test.

$ terraform test
tests/ami_validation.tftest.hcl... in progress
  run "validate_ami_staging"... pass
  run "validate_ami_prod"... fail
╷
│ Error: Test assertion failed
│ 
│   on tests/ami_validation.tftest.hcl line 34, in run "validate_ami_prod":
│   34:     condition = length([
│   35:       for key, ec2_data in local.ec2[var.environment] :
│   36:       key if !strcontains(key, "app") && lookup(ec2_data, "ami", null) != null
│   37:     ]) == length([
│   38:       for key, ec2_data in local.ec2[var.environment] :
│   39:       key if !strcontains(key, "app")
│   40:     ])
│     ├────────────────
│     │ local.ec2 is object with 3 attributes
│     │ var.environment is "prod"
│ 
│ AMIs must be defined for EC2 instances without 'app' in their key in the Prod environment.
╵
tests/ami_validation.tftest.hcl... tearing down
tests/ami_validation.tftest.hcl... fail
tests/ec2_values_mapped.tftest.hcl... in progress
  run "validate_ec2_map_stage"... pass
  run "validate_ec2_vpc_stage"... pass
tests/ec2_values_mapped.tftest.hcl... tearing down
tests/ec2_values_mapped.tftest.hcl... pass

Failure! 3 passed, 1 failed.

Source Code

The code for this article can be found in the ArryWalkerCodebase GitHub repository.

Conclusion

In this article, we've simulated code being run against higher environments without the need to push your code up first. We've set the environment variable so that it applies to all tests in a file, and we've also set the environment variable within a run block so that we can test multiple environments in the same file, all while hopefully providing a few examples of some practically useful unit tests.