Terraform
Testing Functions
When a function is implemented, ensure the function behaves as expected. Follow recommendations to cover how practitioner configurations may call the function.
There are two methodologies for testing provider-defined functions:
- Acceptance Testing: Verify implementation using real Terraform configurations and commands.
- Unit Testing: Verify implementation using with Terraform and framework implementation details.
Similar to other provider concepts, many provider developers prefer acceptance testing over unit testing. Acceptance testing guarantees the function implementation works exactly as expected in real world use cases without trying to determine Terraform or framework implementation details. Unit testing details are provided, however, for function implementations which warrant a broad amount of input value testing, such as generic data handling functions or to perform fuzzing.
Testing examples on this page are dependent on the example echo function implementation.
Recommendations
Testing a provider-defined function should ensure at least the following behaviors are covered:
- Known values return the expected results.
- For any list, map, object, and set parameters, null values for collection elements or object attributes. The
AllowNullValue
parameter setting does not affect Terraform sending these types of null values. - If any parameters enable
AllowNullValue
, null values for those arguments. - If any parameters enable
AllowUnknownValues
, unknown values for those arguments. - Any errors, such as argument validation errors.
Acceptance Testing
Use the plugin testing Go module to implement real world testing with Terraform configurations and commands. The documentation for that Go module covers many more available testing features, however this section example gives a high level overview of how to start writing these tests.
In this example, a echo_function_test.go
file is created:
package provider_test
import (
"testing"
"example.com/terraform-provider-example/internal/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/knownvalue"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfversion"
)
func TestEchoFunction_Valid(t *testing.T) {
t.Parallel()
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_8_0),
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error) {
"example": providerserver.NewProtocol6WithError(provider.New()),
},
Steps: []resource.TestStep{
{
Config: `
output "test" {
value = provider::example::echo("test-value")
}`,
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownOutputValue("test", knownvalue.StringExact("test-value")),
},
},
},
})
}
// The example implementation does not return any errors, however
// this acceptance test verifies how the function should behave if it did.
func TestEchoFunction_Invalid(t *testing.T) {
t.Parallel()
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_8_0),
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error) {
"example": providerserver.NewProtocol6WithError(provider.New()),
},
Steps: []resource.TestStep{
{
Config: `
output "test" {
value = provider::example::echo("invalid")
}`,
ExpectError: regexp.MustCompile(`error summary`),
},
},
})
}
// The example implementation does not enable AllowNullValue, however this
// acceptance test shows how to verify the behavior.
func TestEchoFunction_Null(t *testing.T) {
t.Parallel()
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_8_0),
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error) {
"example": providerserver.NewProtocol6WithError(provider.New()),
},
Steps: []resource.TestStep{
{
Config: `
output "test" {
value = provider::example::echo(null)
}`,
ExpectError: regexp.MustCompile(`Invalid Function Call`),
},
},
})
}
// The example implementation does not enable AllowUnknownValues, however this
// acceptance test shows how to verify the behavior.
func TestEchoFunction_Unknown(t *testing.T) {
resource.UnitTest(t, resource.TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_8_0),
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error) {
"example": providerserver.NewProtocol6WithError(provider.New()),
},
Steps: []resource.TestStep{
{
Config: `
terraform_data "test" {
input = "test-value"
}
output "test" {
value = provider::example::echo(terraform_data.test.output)
}`,
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectUnknownOutputValue("test"),
},
},
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownOutputValue("test", knownvalue.StringExact("test-value")),
},
},
},
})
}
Unit Testing
Use the function.NewArgumentsData()
function and function.NewResultData()
function as part of implementing a Go test.
In this example, a echo_function_test.go
file is created:
package provider_test
import (
"context"
"testing"
"example.com/terraform-provider-example/internal/provider"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/function"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestEchoFunctionRun(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
request function.RunRequest
expected function.RunResponse
}{
// The example implementation uses the Go built-in string type, however
// if AllowNullValue was enabled and *string or types.String was used,
// this test case shows how the function would be expected to behave.
"null": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringNull()}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringNull()),
},
},
// The example implementation uses the Go built-in string type, however
// if AllowUnknownValues was enabled and types.String was used,
// this test case shows how the function would be expected to behave.
"unknown": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringUnknown()}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringUnknown()),
},
},
"value-valid": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue("test-value")}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue("test-value")),
},
},
// The example implementation does not return an error, however
// this test case shows how the function would be expected to behave if
// it did.
"value-invalid": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue("")}),
},
expected: function.RunResponse{
Error: function.NewArgumentFuncError(0, "error summary: error detail"),
Result: function.NewResultData(types.StringUnknown()),
},
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
got := function.RunResponse{
Result: function.NewResultData(types.StringUnknown()),
}
provider.EchoFunction{}.Run(context.Background(), testCase.request, &got)
if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
}
})
}
}