feat(blog): add opentofu/terraform module testing
All checks were successful
/ all (push) Successful in 28s

This commit is contained in:
Julien Dessaux 2025-04-26 18:17:34 +02:00
parent f67eb029a9
commit 8a303476d7
Signed by: adyxax
GPG key ID: F92E51B86E07177E

View file

@ -0,0 +1,188 @@
---
title: 'OpenTofu/Terraform module testing'
description: 'Infrastructure testing is fun!'
date: '2025-04-26'
tags:
- 'aws'
- 'OpenTofu'
- 'terraform'
---
## Introduction
For the last two months, I finally got around to playing with OpenTofu tests.
Infrastructure tests have been teasing me for such a long time! Having mostly
dealt with terraform mono-repos in my career, this was never a big deal because
it is easy to test on just part of an existing environment and therefore never
became a priority.
In the last six months I started building a multi repository system and
thoroughly experimented with testing modules. Here are my thoughts on testing
infrastructure code and a few examples.
## Static checks
Static checks are cheap and useful. Before running terraform tests, [my CI
action](https://git.adyxax.org/adyxax/action-tofu-aws-test) runs the following:
- a `tofu fmt` check to ensure files are properly formatted.
- a `tflint` check to lint the tofu code.
- a `tofu providers lock` to check that the provider locks files all have the
required platform signatures.
Only after all these steps succeed do I run `tofu test`.
## Testing OpenTofu/Terraform code
The OpenTofu/Terraform [test
command](https://opentofu.org/docs/cli/commands/test/) lets you test a
configuration by creating real infrastructure. This means one or more
instantiations of the module in need of testing, as well as support
infrastructure or resources.
Once the test infrastructure is created, assertions are performed to check that
all the conditions relevant to the module's behavior are met. This involves
checking the module's outputs but one usually also use the outputs of various
data-sources.
Once the test is complete, OpenTofu destroys the resources it created. Know
already that this can fail if the test is not written correctly. All terraform
failures are properly handled, but a common failure case I met was when using
the
[external](https://registry.terraform.io/providers/hashicorp/external/latest/docs)
data-source to run some shell commands and capture their outputs.
## Basic example
The simplest form of tofu test is to create a `main.tftest.hcl` file inside a
module. Here is an example for [a module that creates an AWS IAM
role](https://git.adyxax.org/adyxax/tofu-module-aws-iam-role):
``` hcl
provider "aws" {
profile = "tests"
region = "eu-west-3"
}
run "main" {
assert {
condition = output.arn != null
error_message = "invalid IAM role ARN"
}
}
variables {
name = "tftest-role"
}
```
The module is quite simple: its only purpose being to add a bunch of policy
entries. Testing the correct provisioning of the role would be way more code
than the role itself, so this simple test only does a dummy check to confirm
that all the module's resources instantiate properly.
## More elaborate example
A more complete example that actually tests the correct behavior is what I do
with [a module that creates an AWS IAM
user](https://git.adyxax.org/adyxax/tofu-module-aws-iam-user). Here the test is
to log in with the user's access key and check its identity.
The `main.tftest.hcl` is simpler because it relies on a support module:
``` hcl
provider "aws" {
profile = "tests"
region = "eu-west-3"
}
run "main" {
assert {
condition = data.external.main.result.Arn == local.expected_arn
error_message = "user ARN mismatch"
}
module {
source = "./test"
}
}
```
The [support
module](https://git.adyxax.org/adyxax/tofu-module-aws-iam-user/src/branch/main/test)
contains multiple files. The most important one is `main.tf`:
``` hcl
module "main" {
source = "../"
name = "tftest-user"
}
data "aws_caller_identity" "current" {}
# tflint-ignore: terraform_unused_declarations
data "external" "main" {
program = ["${path.module}/test.sh"]
depends_on = [local_file.aws_config]
}
locals {
# tflint-ignore: terraform_unused_declarations
expected_arn = format(
"arn:aws:iam::%s:user/tftest-user",
data.aws_caller_identity.current.account_id,
)
}
resource "local_file" "aws_config" {
filename = "${path.module}/aws_config"
file_permission = "0600"
content = templatefile("${path.module}/aws_config.tftpl", {
aws_access_key_id = module.main.access_key_id
aws_access_key_secret = module.main.access_key_secret
})
}
```
The module `main` is the instantiation of the module we are testing. The other
resources are here to allow a login via the AWS CLI in order to test the access.
Note the `tflint-ignore` directives: They are annoying but needed since tflint
does not know how to reconcile that these are used in of `main.tftest.hcl` file.
The test relies on a `aws_config.tftpl` file containing:
``` ini
[default]
aws_access_key_id = ${aws_access_key_id}
aws_secret_access_key = ${aws_access_key_secret}
region = eu-west-3
```
It also relies on this script:
``` shell
#!/usr/bin/env bash
set -euo pipefail
# Wait a bit for the ACCESS KEY to be usable on AWS
sleep 10
export AWS_CONFIG_FILE="${PWD}/test/aws_config"
aws sts get-caller-identity
```
Note that the `external` data-source works with scripts that take JSON input and
JSON output. Luckily this is what the AWS CLI outputs by default, but if you
changed it you will have to tweak this.
## Conclusion
Writing module tests is worthwhile, even if just to validate the proper
instantiation of a module in its most used configurations. I am now validating
that I can properly spawn VPCs, databases, load balancers, generate
certificates... I have never felt more confident in my OpenTofu/Terraform code!
Though I have found that writing the tofu testing code was not the hardest part:
making it all work in CI was. In a next article I will present the test
infrastructure I use to run all this.