The most surprising thing about Terraform unit tests is that they often don’t need to talk to any real cloud providers at all.
Let’s see what that looks like in practice. Imagine you have a Terraform module that creates an S3 bucket.
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Environment = var.environment
}
}
variable "bucket_name" {
type = string
}
variable "environment" {
type = string
}
Now, you want to test this module without actually creating an S3 bucket in your AWS account. This is where mock providers come in. You’d typically use a tool like terraform test (introduced in Terraform 1.6) or a third-party library like terratest. For this example, let’s focus on terraform test.
In your test file (e.g., modules/s3-bucket/test/s3_bucket_test.go if using Terratest, or a .tftest.hcl file for terraform test), you’d define your test case.
# modules/s3-bucket/test/s3_bucket.tftest.hcl
run "create_bucket" {
command = apply
# This is the crucial part: mocking the provider
# We're telling Terraform to use a "mock" provider for "aws"
# and providing a specific configuration for it.
provider_config {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Use a specific version for reproducibility
mock_service {
# We are mocking the 's3' service within the 'aws' provider
# and specifically defining how the 'CreateBucket' API call
# should behave.
s3 = {
# When 'CreateBucket' is called, we'll return this mock response.
# This response needs to conform to the actual API's expected output.
# The 'bucket' field here is what the AWS provider expects back.
CreateBucket = {
bucket = "mock-bucket-name-from-mock"
}
}
}
}
}
# We then apply our module with specific input variables.
module {
source = "../" # Path to our S3 bucket module
bucket_name = "my-test-bucket"
environment = "dev"
}
# Finally, we assert that the expected resources were created.
# The 'resource_addr' specifies the logical ID of the resource
# within our module's context.
assert {
resource {
# This is the address of the aws_s3_bucket resource within the module.
# The format is module.<module_name>.<resource_type>.<resource_name>
addr = "module.s3_bucket.aws_s3_bucket.this"
}
# We check that the bucket name attribute matches what we expect
# based on our input variables.
attribute "bucket" {
apply_rule "value" {
= "my-test-bucket"
}
}
# We can also check tags.
attribute "tags" {
apply_rule "value" {
= {
Environment = "dev"
}
}
}
}
}
When you run terraform test in the directory containing this .tftest.hcl file, Terraform will:
- Initialize: It will download the
hashicorp/awsprovider (or use a cached version). - Configure Mock: It will intercept any calls the
awsprovider would have made to the real AWS API. Instead of making those calls, it will use themock_serviceconfiguration you provided. - Apply: It will plan and apply your module, simulating resource creation. When the
aws_s3_bucket.thisresource’sCreateBucketoperation is called, the mock service will return the predefinedCreateBucketresponse. - Assert: It will then check the resulting state against your
assertblock, verifying that the attributes of theaws_s3_bucket.thisresource in the mocked state match your expectations.
The mental model here is that Terraform is a declarative system. It plans a set of actions. When you introduce a mock provider, you’re essentially telling Terraform, "For this specific plan and apply operation, when you think you need to talk to AWS, just pretend you got this specific JSON response back from the API, and then continue with your state management and plan evaluation." The mock_service block intercepts the provider’s communication layer, allowing tests to run without external dependencies.
The mock_service configuration for a provider is a direct mapping of the provider’s API calls to predefined responses. You need to consult the actual provider’s documentation or source code to understand what the expected request and response structures are for each API call you want to mock. For example, the CreateBucket response for S3 requires at least a bucket field. If your module’s output or data blocks depend on other attributes from the aws_s3_bucket resource (like arn, id, domain_name, etc.), you’d need to add those to the mock response as well.
The most common pitfall is that the mock response doesn’t perfectly mirror the real API’s output. If the real AWS provider returns a LocationConstraint in the CreateBucket response, and your test relies on that, your mock must include it, even if it’s null or an empty string, to pass the assertion.
The next thing you’ll likely want to explore is mocking more complex provider interactions, like data sources or resources that have dependencies on each other.