feat(blog): add opentofu/terraform module testing
All checks were successful
/ all (push) Successful in 28s
All checks were successful
/ all (push) Successful in 28s
This commit is contained in:
parent
f67eb029a9
commit
8a303476d7
1 changed files with 188 additions and 0 deletions
188
content/blog/terraform/testing.md
Normal file
188
content/blog/terraform/testing.md
Normal 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.
|
Loading…
Add table
Add a link
Reference in a new issue