Infrastructure as Code (IaC) has revolutionised how we manage and deploy infrastructure, but with that power comes the need for robust testing strategies. Both OpenTofu and Terraform now provide native testing frameworks that eliminate the need for external testing tools like Terratest for many use cases. This guide explores practical testing approaches that work seamlessly across both platforms.
The Evolution of Infrastructure Testing
The infrastructure testing landscape has matured significantly. Terraform 1.6 introduced a powerful new testing framework that became generally available, and OpenTofu has maintained compatibility with this testing approach, allowing teams to write tests in HCL rather than learning additional languages like Go.
Why Native Testing Matters
Traditional testing approaches often required:
- Learning additional programming languages (Go for Terratest, Python for other frameworks)
- Complex setup and teardown procedures
- External dependencies and toolchain management
- Separate CI/CD pipeline considerations
The native testing framework addresses these challenges by providing:
- Tests written in the same HCL language you already know
- Built-in state management and cleanup
- Integrated plan and apply testing modes
- Cross-platform compatibility between OpenTofu and Terraform
Getting Started: Your First Test
Let’s start with a practical example. Consider this simple infrastructure module that creates an S3 bucket with specific naming conventions:
# main.tf
variable "environment" {
description = "The environment name"
type = string
validation {
condition = can(regex("^(dev|staging|prod)$", var.environment))
error_message = "Environment must be dev, staging, or prod."
}
}
variable "project_name" {
description = "The project name"
type = string
validation {
condition = length(var.project_name) > 2 && length(var.project_name) < 20
error_message = "Project name must be between 3 and 19 characters."
}
}
resource "aws_s3_bucket" "main" {
bucket = "${var.project_name}-${var.environment}-data"
}
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = var.environment == "prod" ? "Enabled" : "Suspended"
}
}
output "bucket_name" {
description = "The name of the created S3 bucket"
value = aws_s3_bucket.main.id
}
output "versioning_enabled" {
description = "Whether versioning is enabled"
value = aws_s3_bucket_versioning.main.versioning_configuration[0].status == "Enabled"
}
Now let’s create comprehensive tests for this module:
# tests/bucket_naming.tftest.hcl
variables {
environment = "dev"
project_name = "myapp"
}
run "valid_bucket_naming" {
command = plan
assert {
condition = aws_s3_bucket.main.bucket == "myapp-dev-data"
error_message = "Bucket name should follow pattern: project-environment-data"
}
}
run "versioning_disabled_for_dev" {
command = plan
variables {
environment = "dev"
project_name = "testproject"
}
assert {
condition = aws_s3_bucket_versioning.main.versioning_configuration[0].status == "Suspended"
error_message = "Versioning should be disabled for dev environment"
}
}
run "versioning_enabled_for_prod" {
command = plan
variables {
environment = "prod"
project_name = "testproject"
}
assert {
condition = aws_s3_bucket_versioning.main.versioning_configuration[0].status == "Enabled"
error_message = "Versioning should be enabled for prod environment"
}
}
Testing Validation Logic
One of the most powerful aspects of the native testing framework is the ability to test validation failures:
# tests/validation.tftest.hcl
run "invalid_environment_fails" {
command = plan
variables {
environment = "invalid"
project_name = "myapp"
}
expect_failures = [
var.environment,
]
}
run "project_name_too_short_fails" {
command = plan
variables {
environment = "dev"
project_name = "xy"
}
expect_failures = [
var.project_name,
]
}
run "project_name_too_long_fails" {
command = plan
variables {
environment = "dev"
project_name = "this-name-is-way-too-long-for-our-validation"
}
expect_failures = [
var.project_name,
]
}
Advanced Testing: Mocking and Overrides
For complex infrastructure that you don’t want to actually deploy during testing, both OpenTofu and Terraform support mocking and resource overrides:
# tests/integration.tftest.hcl
# Mock the AWS provider to avoid actual resource creation
mock_provider "aws" {
alias = "mock"
}
run "integration_test_with_mocks" {
providers = {
aws = aws.mock
}
variables {
environment = "prod"
project_name = "integration-test"
}
assert {
condition = aws_s3_bucket.main.bucket == "integration-test-prod-data"
error_message = "Bucket naming integration failed"
}
assert {
condition = output.versioning_enabled == true
error_message = "Prod environment should have versioning enabled"
}
}
# Override specific resources for testing edge cases
run "test_with_overrides" {
override_resource {
target = aws_s3_bucket_versioning.main
values = {
versioning_configuration = [{
status = "Enabled"
}]
}
}
variables {
environment = "dev"
project_name = "override-test"
}
assert {
condition = output.versioning_enabled == true
error_message = "Override should force versioning to be enabled"
}
}
Testing with Helper Modules
Sometimes you need additional resources for testing that aren’t part of your main module. The native framework supports this through helper modules:
# test-helpers/http-check/main.tf
variable "bucket_name" {
description = "Bucket name to check"
type = string
}
# Load the main module being tested
module "main" {
source = "../../"
environment = var.environment
project_name = var.project_name
}
# Add test-specific resources
data "aws_s3_bucket" "test_check" {
bucket = module.main.bucket_name
depends_on = [module.main]
}
output "bucket_exists" {
value = data.aws_s3_bucket.test_check.id != ""
}
output "main_outputs" {
value = {
bucket_name = module.main.bucket_name
versioning_enabled = module.main.versioning_enabled
}
}
# tests/existence_check.tftest.hcl
variables {
environment = "dev"
project_name = "existence-test"
}
run "bucket_actually_exists" {
module {
source = "./test-helpers/http-check"
}
assert {
condition = output.bucket_exists == true
error_message = "Bucket should exist after creation"
}
assert {
condition = output.main_outputs.bucket_name == "existence-test-dev-data"
error_message = "Helper module should pass through correct bucket name"
}
}
Testing Strategies: Unit vs Integration
Unit Testing (Plan Mode)
Use command = plan
for fast feedback on configuration logic:
run "unit_test_bucket_config" {
command = plan
# Fast execution, no real resources created
assert {
condition = aws_s3_bucket.main.bucket != ""
error_message = "Bucket name must not be empty"
}
}
Integration Testing (Apply Mode)
Use the default command = apply
for end-to-end validation:
run "integration_test_full_stack" {
# This will actually create and destroy resources
variables {
environment = "dev"
project_name = "integration"
}
assert {
condition = aws_s3_bucket.main.id != ""
error_message = "Bucket should be successfully created"
}
}
Cross-Platform Compatibility
Both OpenTofu and Terraform support the same testing syntax, but there are subtle differences to be aware of:
File Extensions
- Terraform: Uses
.tftest.hcl
and.tftest.json
- OpenTofu: Supports both
.tftest.hcl
/.tftest.json
AND.tofutest.hcl
/.tofutest.json
- Best Practice: Stick with
.tftest.hcl
for maximum compatibility
Running Tests
# Terraform
terraform test
# OpenTofu
tofu test
# Both support the same options
terraform test -filter=specific_test.tftest.hcl
tofu test -filter=specific_test.tftest.hcl
Organizing Your Test Suite
Directory Structure Options
Option 1: Co-located Tests
.
├── main.tf
├── variables.tf
├── outputs.tf
├── basic_functionality.tftest.hcl
├── validation_rules.tftest.hcl
└── integration_tests.tftest.hcl
Option 2: Separate Tests Directory
.
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
├── basic_functionality.tftest.hcl
├── validation_rules.tftest.hcl
├── integration_tests.tftest.hcl
└── helpers/
└── test-setup/
Test Naming Conventions
Use descriptive names that indicate the test purpose:
validation_*.tftest.hcl
- Tests for input validationunit_*.tftest.hcl
- Fast unit tests using plan modeintegration_*.tftest.hcl
- Full integration testsedge_case_*.tftest.hcl
- Tests for unusual scenarios
Best Practices and Recommendations
1. Layer Your Testing Strategy
- Validation Tests: Test input validation and constraints
- Unit Tests: Test configuration logic using plan mode
- Integration Tests: Test real resource creation selectively
- Smoke Tests: Quick health checks for critical functionality
2. Use Mocks Strategically
Don’t mock everything - use mocks for:
- Expensive resources (large compute instances)
- Resources with external dependencies
- Third-party services that don’t need actual validation
3. Test Both Success and Failure Paths
run "valid_input_succeeds" {
variables {
environment = "prod"
}
assert {
condition = aws_s3_bucket.main.id != ""
error_message = "Valid input should create bucket"
}
}
run "invalid_input_fails" {
variables {
environment = "invalid"
}
expect_failures = [var.environment]
}
4. Keep Tests Independent
Each run
block should be independent and not rely on state from other tests:
# Good - each test is self-contained
run "test_dev_environment" {
variables {
environment = "dev"
project_name = "test-dev"
}
# ... assertions
}
run "test_prod_environment" {
variables {
environment = "prod"
project_name = "test-prod"
}
# ... assertions
}
5. Use Descriptive Error Messages
assert {
condition = length(aws_s3_bucket.main.bucket) <= 63
error_message = "S3 bucket name '${aws_s3_bucket.main.bucket}' exceeds 63 character limit (current: ${length(aws_s3_bucket.main.bucket)})"
}
Conclusion
The native testing frameworks in both OpenTofu and Terraform represent a significant step forward in infrastructure testing maturity. By leveraging HCL for tests, teams can maintain consistency in their toolchain while achieving comprehensive test coverage.
Key takeaways:
- Use plan mode for fast unit tests, apply mode for integration tests
- Leverage mocking and overrides for complex scenarios
- Maintain cross-platform compatibility by using standard
.tftest.hcl
files - Structure tests logically and keep them independent
- Integrate testing into your CI/CD pipeline from the start
The days of requiring external testing frameworks for most infrastructure testing scenarios are behind us. With native testing capabilities, teams can build robust, reliable infrastructure with confidence, regardless of whether they choose OpenTofu or Terraform.
As the infrastructure as code landscape continues to evolve, having a solid testing strategy will become even more critical. The native testing framework provides the foundation for that strategy, enabling teams to catch issues early, validate changes confidently, and maintain high-quality infrastructure code.
Ready to start testing your infrastructure? Begin with simple validation tests and gradually expand to more comprehensive integration scenarios. Your future self (and your team) will thank you for the investment in testing discipline.