diff --git a/.changes/unreleased/FEATURES-20231122-084316.yaml b/.changes/unreleased/FEATURES-20231122-084316.yaml new file mode 100644 index 000000000..2d83fa465 --- /dev/null +++ b/.changes/unreleased/FEATURES-20231122-084316.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectUnknownOutputValue` built-in plan check, which asserts + that a given output value at a specified address is unknown' +time: 2023-11-22T08:43:16.202152Z +custom: + Issue: "220" diff --git a/.changes/unreleased/FEATURES-20231122-084409.yaml b/.changes/unreleased/FEATURES-20231122-084409.yaml new file mode 100644 index 000000000..0c7dc4ad0 --- /dev/null +++ b/.changes/unreleased/FEATURES-20231122-084409.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectUnknownOutputValueAtPath` built-in plan check, which + asserts that a given output value at a specified address, and path is unknown' +time: 2023-11-22T08:44:09.522934Z +custom: + Issue: "220" diff --git a/.changes/unreleased/FEATURES-20231122-084501.yaml b/.changes/unreleased/FEATURES-20231122-084501.yaml new file mode 100644 index 000000000..de09f1935 --- /dev/null +++ b/.changes/unreleased/FEATURES-20231122-084501.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectNullOutputValue` built-in plan check, which asserts + that a given output value at a specified address is null' +time: 2023-11-22T08:45:01.330523Z +custom: + Issue: "220" diff --git a/.changes/unreleased/FEATURES-20231122-084552.yaml b/.changes/unreleased/FEATURES-20231122-084552.yaml new file mode 100644 index 000000000..77259b21d --- /dev/null +++ b/.changes/unreleased/FEATURES-20231122-084552.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectNullOutputValueAtPath` built-in plan check, which asserts + that a given output value at a specified address, and path is null' +time: 2023-11-22T08:45:52.856267Z +custom: + Issue: "220" diff --git a/plancheck/expect_null_output_value.go b/plancheck/expect_null_output_value.go new file mode 100644 index 000000000..540462bf0 --- /dev/null +++ b/plancheck/expect_null_output_value.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectNullOutputValue{} + +type expectNullOutputValue struct { + outputAddress string +} + +// CheckPlan implements the plan check logic. +func (e expectNullOutputValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var change *tfjson.Change + + for address, oc := range req.Plan.OutputChanges { + if e.outputAddress == address { + change = oc + + break + } + } + + if change == nil { + resp.Error = fmt.Errorf("%s - Output not found in plan OutputChanges", e.outputAddress) + + return + } + + var result any + var err error + + switch { + case change.Actions.Create(): + result, err = tfjsonpath.Traverse(change.After, tfjsonpath.Path{}) + default: + result, err = tfjsonpath.Traverse(change.Before, tfjsonpath.Path{}) + } + + if err != nil { + resp.Error = err + + return + } + + if result != nil { + resp.Error = fmt.Errorf("attribute at path is not null") + + return + } +} + +// ExpectNullOutputValue returns a plan check that asserts that the specified output has a null value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of null +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of null values, such +// as marking whole maps as null rather than individual element values. +func ExpectNullOutputValue(outputAddress string) PlanCheck { + return expectNullOutputValue{ + outputAddress: outputAddress, + } +} diff --git a/plancheck/expect_null_output_value_at_path.go b/plancheck/expect_null_output_value_at_path.go new file mode 100644 index 000000000..17366e21a --- /dev/null +++ b/plancheck/expect_null_output_value_at_path.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectNullOutputValueAtPath{} + +type expectNullOutputValueAtPath struct { + outputAddress string + valuePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectNullOutputValueAtPath) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var change *tfjson.Change + + for address, oc := range req.Plan.OutputChanges { + if e.outputAddress == address { + change = oc + + break + } + } + + if change == nil { + resp.Error = fmt.Errorf("%s - Output not found in plan OutputChanges", e.outputAddress) + + return + } + + var result any + var err error + + switch { + case change.Actions.Create(): + result, err = tfjsonpath.Traverse(change.After, e.valuePath) + default: + result, err = tfjsonpath.Traverse(change.Before, e.valuePath) + } + + if err != nil { + resp.Error = err + + return + } + + if result != nil { + resp.Error = fmt.Errorf("attribute at path is not null") + + return + } +} + +// ExpectNullOutputValueAtPath returns a plan check that asserts that the specified output has a null value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of null +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of null values, such +// as marking whole maps as null rather than individual element values. +func ExpectNullOutputValueAtPath(outputAddress string, valuePath tfjsonpath.Path) PlanCheck { + return expectNullOutputValueAtPath{ + outputAddress: outputAddress, + valuePath: valuePath, + } +} diff --git a/plancheck/expect_null_output_value_at_path_test.go b/plancheck/expect_null_output_value_at_path_test.go new file mode 100644 index 000000000..350ff4a33 --- /dev/null +++ b/plancheck/expect_null_output_value_at_path_test.go @@ -0,0 +1,523 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func Test_ExpectNullOutputValueAtPath_StringAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_StringAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + string_attribute = null + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_StringAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + string_attribute = "str" + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("string_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_attribute = null + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_attribute = ["one", "two"] + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_SetAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_SetAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_attribute = null + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_SetAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_attribute = ["one", "two"] + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_MapAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_MapAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = null + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_MapAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = { + "one": "str", + "two": "str" + } + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("map_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_MapAttribute_PartiallyNullConfig_ExpectError(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = { + key1 = "value1", + key2 = null + } + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("map_attribute").AtMapKey("key2")), + }, + }, + ExpectError: regexp.MustCompile(`path not found: specified key key2 not found in map at map_attribute.key2`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListNestedBlock_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block {} + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListNestedBlock_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block { + list_nested_block_attribute = null + } + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_ListNestedBlock_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block { + list_nested_block_attribute = "str" + } + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValueAtPath_SetNestedBlock_NullConfig_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_nested_block { + set_nested_block_attribute = null + } + } + + output "resource" { + value = test_resource.test + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValueAtPath("resource", tfjsonpath.New("set_nested_block")), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} diff --git a/plancheck/expect_null_output_value_test.go b/plancheck/expect_null_output_value_test.go new file mode 100644 index 000000000..b4e85dcbf --- /dev/null +++ b/plancheck/expect_null_output_value_test.go @@ -0,0 +1,522 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_ExpectNullOutputValue_StringAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + } + + output "string_attribute" { + value = test_resource.test.string_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("string_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_StringAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + string_attribute = null + } + + output "string_attribute" { + value = test_resource.test.string_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("string_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_StringAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + Steps: []r.TestStep{ + { + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Config: `resource "test_resource" "test" { + string_attribute = "str" + } + + output "string_attribute" { + value = test_resource.test.string_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("string_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "list_attribute" { + value = test_resource.test.list_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_attribute = null + } + + output "list_attribute" { + value = test_resource.test.list_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_attribute = ["one", "two"] + } + + output "list_attribute" { + value = test_resource.test.list_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_SetAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "set_attribute" { + value = test_resource.test.set_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("set_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_SetAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_attribute = null + } + + output "set_attribute" { + value = test_resource.test.set_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("set_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_SetAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_attribute = ["one", "two"] + } + + output "set_attribute" { + value = test_resource.test.set_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("set_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_MapAttribute_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + } + + output "map_attribute" { + value = test_resource.test.map_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("map_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_MapAttribute_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = null + } + + output "map_attribute" { + value = test_resource.test.map_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("map_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_MapAttribute_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = { + "one": "str", + "two": "str" + } + } + + output "map_attribute" { + value = test_resource.test.map_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("map_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_MapAttribute_PartiallyNullConfig_ExpectError(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + map_attribute = { + key1 = "value1", + key2 = null + } + } + + output "map_attribute" { + value = test_resource.test.map_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("key2"), + }, + }, + ExpectError: regexp.MustCompile(`key2 - Output not found in plan OutputChanges`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListNestedBlock_EmptyConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block {} + } + + output "list_nested_block_attribute" { + value = test_resource.test.list_nested_block.0.list_nested_block_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_nested_block_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListNestedBlock_NullConfig(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block { + list_nested_block_attribute = null + } + } + + output "list_nested_block_attribute" { + value = test_resource.test.list_nested_block.0.list_nested_block_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_nested_block_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectNullOutputValue_ListNestedBlock_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + list_nested_block { + list_nested_block_attribute = "str" + } + } + + output "list_nested_block_attribute" { + value = test_resource.test.list_nested_block.0.list_nested_block_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("list_nested_block_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} + +func Test_ExpectNullOutputValue_SetNestedBlock_NullConfig_ExpectErrorNotNull(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + set_nested_block { + set_nested_block_attribute = null + } + } + + output "set_nested_block" { + value = test_resource.test.set_nested_block + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNullOutputValue("set_nested_block"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is not null`), + }, + }, + }) +} diff --git a/plancheck/expect_unknown_output_value.go b/plancheck/expect_unknown_output_value.go new file mode 100644 index 000000000..f3af398c9 --- /dev/null +++ b/plancheck/expect_unknown_output_value.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectUnknownOutputValue{} + +type expectUnknownOutputValue struct { + outputAddress string +} + +// CheckPlan implements the plan check logic. +func (e expectUnknownOutputValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var change *tfjson.Change + + for address, oc := range req.Plan.OutputChanges { + if e.outputAddress == address { + change = oc + + break + } + } + + if change == nil { + resp.Error = fmt.Errorf("%s - Output not found in plan OutputChanges", e.outputAddress) + + return + } + + result, err := tfjsonpath.Traverse(change.AfterUnknown, tfjsonpath.Path{}) + + if err != nil { + resp.Error = err + + return + } + + isUnknown, ok := result.(bool) + + if !ok { + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") + + return + } + + if !isUnknown { + resp.Error = fmt.Errorf("attribute at path is known") + + return + } +} + +// ExpectUnknownOutputValue returns a plan check that asserts that the specified output has an unknown value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of unknown +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of unknown values, such +// as marking whole maps as unknown rather than individual element values. +func ExpectUnknownOutputValue(outputAddress string) PlanCheck { + return expectUnknownOutputValue{ + outputAddress: outputAddress, + } +} diff --git a/plancheck/expect_unknown_output_value_at_path.go b/plancheck/expect_unknown_output_value_at_path.go new file mode 100644 index 000000000..74f694e2f --- /dev/null +++ b/plancheck/expect_unknown_output_value_at_path.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectUnknownOutputValueAtPath{} + +type expectUnknownOutputValueAtPath struct { + outputAddress string + valuePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectUnknownOutputValueAtPath) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var change *tfjson.Change + + for address, oc := range req.Plan.OutputChanges { + if e.outputAddress == address { + change = oc + + break + } + } + + if change == nil { + resp.Error = fmt.Errorf("%s - Output not found in plan OutputChanges", e.outputAddress) + + return + } + + result, err := tfjsonpath.Traverse(change.AfterUnknown, e.valuePath) + + if err != nil { + resp.Error = err + + return + } + + isUnknown, ok := result.(bool) + + if !ok { + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") + + return + } + + if !isUnknown { + resp.Error = fmt.Errorf("attribute at path is known") + + return + } +} + +// ExpectUnknownOutputValueAtPath returns a plan check that asserts that the specified output has an unknown value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of unknown +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of unknown values, such +// as marking whole maps as unknown rather than individual element values. +func ExpectUnknownOutputValueAtPath(outputAddress string, valuePath tfjsonpath.Path) PlanCheck { + return expectUnknownOutputValueAtPath{ + outputAddress: outputAddress, + valuePath: valuePath, + } +} diff --git a/plancheck/expect_unknown_output_value_at_path_test.go b/plancheck/expect_unknown_output_value_at_path_test.go new file mode 100644 index 000000000..5518ce6b0 --- /dev/null +++ b/plancheck/expect_unknown_output_value_at_path_test.go @@ -0,0 +1,383 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func Test_ExpectUnknownOutputValueAtPath_StringAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + output "resource" { + value = terraform_data.one + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("output")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ListAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_attribute = ["value1", terraform_data.one.output] + } + + output "resource" { + value = test_resource.two + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_SetAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + set_attribute = ["value1", terraform_data.one.output] + } + + output "resource" { + value = test_resource.two + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_MapAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + map_attribute = { + key1 = "value1", + key2 = terraform_data.one.output + } + } + + output "resource" { + value = test_resource.two + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ListNestedBlock_Resource(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = terraform_data.one.output + } + } + + output "resource" { + value = test_resource.two + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ListNestedBlock_ResourceBlocks(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = terraform_data.one.output + } + } + + output "resource_blocks" { + value = test_resource.two.list_nested_block + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource_blocks", tfjsonpath.New(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ListNestedBlock_ObjectBlockIndex(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = terraform_data.one.output + } + } + + output "resource_blocks_index" { + value = test_resource.two.list_nested_block.0 + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource_blocks_index", tfjsonpath.New("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_SetNestedBlock_Object(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + set_nested_block { + set_nested_block_attribute = terraform_data.one.output + } + } + + output "resource" { + value = test_resource.two + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("set_nested_block").AtSliceIndex(0).AtMapKey("set_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ExpectError_KnownValue(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_attribute = ["value1"] + } + + output "resource" { + value = test_resource.one + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("resource", tfjsonpath.New("set_attribute").AtSliceIndex(0)), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is known`), + }, + }, + }) +} + +func Test_ExpectUnknownOutputValueAtPath_ExpectError_OutputNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_attribute = ["value1"] + } + + output "output_one" { + value = test_resource.one + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("output_two", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`output_two - Output not found in plan OutputChanges`), + }, + }, + }) +} diff --git a/plancheck/expect_unknown_output_value_test.go b/plancheck/expect_unknown_output_value_test.go new file mode 100644 index 000000000..6371693eb --- /dev/null +++ b/plancheck/expect_unknown_output_value_test.go @@ -0,0 +1,260 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_ExpectUnknownOutputValue_StringAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + output "string_attribute" { + value = terraform_data.one.output + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("string_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_ListAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_attribute = ["value1", terraform_data.one.output] + } + + output "list_attribute" { + value = test_resource.two.list_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("list_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_SetAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + set_attribute = ["value1", terraform_data.one.output] + } + + output "set_attribute" { + value = test_resource.two.set_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("set_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_MapAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + map_attribute = { + key1 = "value1", + key2 = terraform_data.one.output + } + } + + output "map_attribute" { + value = test_resource.two.map_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("map_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_ListNestedBlock(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "data": { + Source: "terraform.io/builtin/terraform", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "terraform_data" "one" { + input = "string" + } + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = terraform_data.one.output + } + } + + output "list_nested_block_attribute" { + value = test_resource.two.list_nested_block.0.list_nested_block_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("list_nested_block_attribute"), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_ExpectError_KnownValue(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_attribute = ["value1"] + } + + output "set_attribute" { + value = test_resource.one.set_attribute + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("set_attribute"), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is known`), + }, + }, + }) +} + +func Test_ExpectUnknownOutputValue_ExpectError_OutputNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" {} + + output "output_one" { + value = test_resource.one + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("output_two"), + }, + }, + ExpectError: regexp.MustCompile(`output_two - Output not found in plan OutputChanges`), + }, + }, + }) +} diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go index ef3930dfb..980cf1fea 100644 --- a/tfjsonpath/path.go +++ b/tfjsonpath/path.go @@ -5,6 +5,7 @@ package tfjsonpath import ( "fmt" + "strings" ) // Path represents exact traversal steps specifying a value inside @@ -38,13 +39,25 @@ type Path struct { steps []step } -// New creates a new path with an initial MapStep. -func New(name string) Path { - return Path{ - steps: []step{ - MapStep(name), - }, +// New creates a new path with an initial MapStep or SliceStep. +func New[T int | string](firstStep T) Path { + switch t := any(firstStep).(type) { + case int: + return Path{ + steps: []step{ + SliceStep(t), + }, + } + case string: + return Path{ + steps: []step{ + MapStep(t), + }, + } } + + // Unreachable code + return Path{} } // AtSliceIndex returns a copied Path with a new SliceStep at the end. @@ -69,34 +82,38 @@ func (s Path) AtMapKey(key string) Path { // is not found in the given object or if the given object does not // conform to format of Terraform JSON data. func Traverse(object any, attrPath Path) (any, error) { - _, ok := object.(map[string]any) - - if !ok { - return nil, fmt.Errorf("cannot convert given object to map[string]any") - } - result := object + var steps []string + for _, step := range attrPath.steps { switch s := step.(type) { case MapStep: + steps = append(steps, string(s)) + mapObj, ok := result.(map[string]any) + if !ok { - return nil, fmt.Errorf("path not found: cannot convert object at MapStep %s to map[string]any", string(s)) + return nil, fmt.Errorf("path not found: cannot convert object at MapStep %s to map[string]any", strings.Join(steps, ".")) } + result, ok = mapObj[string(s)] + if !ok { - return nil, fmt.Errorf("path not found: specified key %s not found in map", string(s)) + return nil, fmt.Errorf("path not found: specified key %s not found in map at %s", string(s), strings.Join(steps, ".")) } case SliceStep: + steps = append(steps, fmt.Sprint(s)) + sliceObj, ok := result.([]any) + if !ok { - return nil, fmt.Errorf("path not found: cannot convert object at SliceStep %d to []any", s) + return nil, fmt.Errorf("path not found: cannot convert object at SliceStep %s to []any", strings.Join(steps, ".")) } if int(s) >= len(sliceObj) { - return nil, fmt.Errorf("path not found: SliceStep index %d is out of range with slice length %d", s, len(sliceObj)) + return nil, fmt.Errorf("path not found: SliceStep index %s is out of range with slice length %d", strings.Join(steps, "."), len(sliceObj)) } result = sliceObj[s] diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go index 9a37c9918..e18448728 100644 --- a/tfjsonpath/path_test.go +++ b/tfjsonpath/path_test.go @@ -25,6 +25,22 @@ func Test_Traverse_StringValue(t *testing.T) { } } +func Test_Traverse_Array_StringValue(t *testing.T) { + t.Parallel() + + path := New(0).AtMapKey("StringValue") + + actual, err := Traverse(createTestArray(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := "example" + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + func Test_Traverse_NumberValue(t *testing.T) { t.Parallel() @@ -41,6 +57,22 @@ func Test_Traverse_NumberValue(t *testing.T) { } } +func Test_Traverse_Array_NumberValue(t *testing.T) { + t.Parallel() + + path := New(0).AtMapKey("NumberValue") + + actual, err := Traverse(createTestArray(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := 0.0 + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + func Test_Traverse_BooleanValue(t *testing.T) { t.Parallel() @@ -57,6 +89,22 @@ func Test_Traverse_BooleanValue(t *testing.T) { } } +func Test_Traverse_Array_BooleanValue(t *testing.T) { + t.Parallel() + + path := New(0).AtMapKey("BooleanValue") + + actual, err := Traverse(createTestArray(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := false + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + func Test_Traverse_NullValue(t *testing.T) { t.Parallel() @@ -72,6 +120,21 @@ func Test_Traverse_NullValue(t *testing.T) { } } +func Test_Traverse_Array_NullValue(t *testing.T) { + t.Parallel() + + path := New(0).AtMapKey("NullValue") + + actual, err := Traverse(createTestArray(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + + if actual != nil { + t.Errorf("Output %v not equal to expected %v", actual, nil) + } +} + func Test_Traverse_Array(t *testing.T) { t.Parallel() @@ -122,6 +185,56 @@ func Test_Traverse_Array(t *testing.T) { } } +func Test_Traverse_Array_Array(t *testing.T) { + t.Parallel() + + testCases := []struct { + path Path + expected any + }{ + { + path: New(0).AtMapKey("Array").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(2), + expected: "example2", + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(3), + expected: nil, + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(4), + expected: true, + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(5).AtMapKey("NestedStringValue"), + expected: "example3", + }, + { + path: New(0).AtMapKey("Array").AtSliceIndex(6).AtSliceIndex(0), + expected: true, + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestArray(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + func Test_Traverse_Object(t *testing.T) { t.Parallel() @@ -180,6 +293,64 @@ func Test_Traverse_Object(t *testing.T) { } } +func Test_Traverse_Array_Object(t *testing.T) { + t.Parallel() + + testCases := []struct { + path Path + expected any + }{ + { + path: New(0).AtMapKey("Object").AtMapKey("StringValue"), + expected: "example", + }, + { + path: New(0).AtMapKey("Object").AtMapKey("NumberValue"), + expected: 0.0, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("BooleanValue"), + expected: false, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ArrayValue").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ArrayValue").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ArrayValue").AtSliceIndex(2), + expected: "example2", + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ArrayValue").AtSliceIndex(3), + expected: nil, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ArrayValue").AtSliceIndex(4), + expected: true, + }, + { + path: New(0).AtMapKey("Object").AtMapKey("ObjectValue").AtMapKey("NestedStringValue"), + expected: "example3", + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestArray(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + func Test_Traverse_ExpectError(t *testing.T) { t.Parallel() @@ -191,13 +362,13 @@ func Test_Traverse_ExpectError(t *testing.T) { { path: New("ObjectA"), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: specified key ObjectA not found in map`) + return strings.Contains(err.Error(), `path not found: specified key ObjectA not found in map at ObjectA`) }, }, { path: New("Object").AtMapKey("MapValueA"), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: specified key MapValueA not found in map`) + return strings.Contains(err.Error(), `path not found: specified key MapValueA not found in map at Object.MapValueA`) }, }, @@ -205,19 +376,19 @@ func Test_Traverse_ExpectError(t *testing.T) { { path: New("StringValue").AtSliceIndex(0), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep`) + return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep StringValue.0 to []any`) }, }, { path: New("StringValue").AtMapKey("MapKeyA"), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep StringValue.MapKeyA to map[string]any`) }, }, { path: New("Array").AtSliceIndex(0).AtMapKey("MapValueA"), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep Array.0.MapValueA to map[string]any`) }, }, @@ -225,13 +396,13 @@ func Test_Traverse_ExpectError(t *testing.T) { { path: New("Array").AtSliceIndex(10), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: SliceStep index 10 is out of range with slice length 7`) + return strings.Contains(err.Error(), `path not found: SliceStep index Array.10 is out of range with slice length 7`) }, }, { path: New("Array").AtSliceIndex(7), expectedError: func(err error) bool { - return strings.Contains(err.Error(), `path not found: SliceStep index 7 is out of range with slice length 7`) + return strings.Contains(err.Error(), `path not found: SliceStep index Array.7 is out of range with slice length 7`) }, }, } @@ -248,6 +419,75 @@ func Test_Traverse_ExpectError(t *testing.T) { } } +func Test_Traverse_Array_ExpectError(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + path Path + expectedError func(err error) bool + }{ + // specified index not found + "unknown_index": { + path: New(1), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 1 is out of range with slice length 1`) + }, + }, + "unknown_nested_index": { + path: New(0).AtSliceIndex(0), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep 0.0 to []any`) + }, + }, + + // cannot convert object + "unknown_map_index": { + path: New(0).AtMapKey("StringValue").AtSliceIndex(0), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep 0.StringValue.0 to []any`) + }, + }, + "unknown_map_key": { + path: New(0).AtMapKey("StringValue").AtMapKey("MapKeyA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep 0.StringValue.MapKeyA to map[string]any`) + }, + }, + "unknown_slice_map_key": { + path: New(0).AtMapKey("Array").AtSliceIndex(0).AtMapKey("MapValueA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep 0.Array.0.MapValueA to map[string]any`) + }, + }, + + // index out of bounds + "out_of_bounds": { + path: New(0).AtMapKey("Array").AtSliceIndex(10), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 0.Array.10 is out of range with slice length 7`) + }, + }, + } + + for name, tc := range testCases { + name, tc := name, tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := Traverse(createTestArray(), tc.path) + + if err == nil { + t.Fatalf("Expected error but got none") + } + + if !tc.expectedError(err) { + t.Errorf("Unexpected error: %s", err) + } + }) + } +} + func createTestObject() any { var jsonObject any jsonstring := @@ -274,3 +514,30 @@ func createTestObject() any { return jsonObject } + +func createTestArray() any { + var jsonObject any + jsonstring := + `[{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "NullValue": null, + "Array": [10, 15.2, "example2", null, true, {"NestedStringValue": "example3"}, [true]], + "Object":{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "ArrayValue": [10, 15.2, "example2", null, true], + "ObjectValue": { + "NestedStringValue": "example3" + } + } + }]` + err := json.Unmarshal([]byte(jsonstring), &jsonObject) + if err != nil { + return nil + } + + return jsonObject +} diff --git a/tfjsonpath/step.go b/tfjsonpath/step.go index 7b6813d60..5a779640d 100644 --- a/tfjsonpath/step.go +++ b/tfjsonpath/step.go @@ -5,7 +5,7 @@ package tfjsonpath // step represents a traversal type indicating the underlying Go type // representation for a Terraform JSON value. -type step interface{} +type step any // MapStep represents a traversal for map[string]any type MapStep string diff --git a/website/data/plugin-testing-nav-data.json b/website/data/plugin-testing-nav-data.json index b65c9fecd..72d37fdcb 100644 --- a/website/data/plugin-testing-nav-data.json +++ b/website/data/plugin-testing-nav-data.json @@ -31,7 +31,24 @@ }, { "title": "Plan Checks", - "path": "acceptance-tests/plan-checks" + "routes": [ + { + "title": "Overview", + "path": "acceptance-tests/plan-checks" + }, + { + "title": "Resource Plan Checks", + "path": "acceptance-tests/plan-checks/resource" + }, + { + "title": "Output Plan Checks", + "path": "acceptance-tests/plan-checks/output" + }, + { + "title": "Custom Plan Checks", + "path": "acceptance-tests/plan-checks/custom" + } + ] }, { "title": "Sweepers", diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks/custom.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks/custom.mdx new file mode 100644 index 000000000..5e3672a51 --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks/custom.mdx @@ -0,0 +1,87 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Plan Checks' +description: >- + Plan Checks are test assertions that can inspect a plan at different phases in a TestStep. Custom Plan Checks can be implemented. +--- + +# Custom Plan Checks + +The package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) also provides the [`PlanCheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#PlanCheck) interface, which can be implemented for a custom plan check. + +The [`plancheck.CheckPlanRequest`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#CheckPlanRequest) contains the current plan file, parsed by the [terraform-json package](https://pkg.go.dev/github.com/hashicorp/terraform-json#Plan). + +Here is an example implementation of a plan check that asserts that every resource change is a no-op, aka, an empty plan: +```go +package example_test + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +var _ plancheck.PlanCheck = expectEmptyPlan{} + +type expectEmptyPlan struct{} + +func (e expectEmptyPlan) CheckPlan(ctx context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) { + var result error + + for _, rc := range req.Plan.ResourceChanges { + if !rc.Change.Actions.NoOp() { + result = errors.Join(result, fmt.Errorf("expected empty plan, but %s has planned action(s): %v", rc.Address, rc.Change.Actions)) + } + } + + resp.Error = result +} + +func ExpectEmptyPlan() plancheck.PlanCheck { + return expectEmptyPlan{} +} +``` + +And example usage: +```go +package example_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_CustomPlanCheck_ExpectEmptyPlan(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ExternalProviders: map[string]resource.ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []resource.TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + }, + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} +``` diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks/index.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks/index.mdx new file mode 100644 index 000000000..b989b786d --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks/index.mdx @@ -0,0 +1,71 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Plan Checks' +description: >- + Plan Checks are test assertions that can inspect a plan at different phases in a TestStep. The testing module + provides built-in Plan Checks for common use-cases, and custom Plan Checks can also be implemented. +--- + +# Plan Checks + +During the **Lifecycle (config)** and **Refresh** [modes](/terraform/plugin/testing/acceptance-tests/teststep#test-modes) of a `TestStep`, the testing framework will run `terraform plan` before and after certain operations. For example, the **Lifecycle (config)** mode will run a plan before the `terraform apply` phase, as well as a plan before and after the `terraform refresh` phase. + +These `terraform plan` operations results in a [plan file](/terraform/cli/commands/plan#out-filename) and can be represented by this [JSON format](/terraform/internals/json-format#plan-representation). + +A **plan check** is a test assertion that inspects the plan file at a specific phase during the current testing mode. Multiple plan checks can be run at each defined phase, all assertion errors returned are aggregated, reported as a test failure, and all test cleanup logic is executed. + +- Available plan phases for **Lifecycle (config)** mode are defined in the [`TestStep.ConfigPlanChecks`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/helper/resource#TestStep) struct +- Available plan phases for **Refresh** mode are defined in the [`TestStep.RefreshPlanChecks`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/helper/resource#TestStep) struct +- **Import** mode currently does not run any plan operations, and therefore does not support plan checks. + +Refer to: + +- [General Plan Checks](#general-plan-checks) for built-in general purpose plan checks. +- [Resource Plan Checks](/terraform/plugin/testing/acceptance-tests/plan-checks/resource) for built-in managed resource and data source plan checks. +- [Output Plan Checks](/terraform/plugin/testing/acceptance-tests/plan-checks/output) for built-in output-related plan checks. +- [Custom Plan Checks](/terraform/plugin/testing/acceptance-tests/plan-checks/custom) for defining bespoke plan checks. + +## General Plan Checks + +The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in general plan checks for common use-cases: + +| Check | Description | +|-----------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| +| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | +| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | + +## Examples using `plancheck.ExpectEmptyPlan` + +One of the built-in plan checks, [`plancheck.ExpectEmptyPlan`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan), is useful for determining a plan is a no-op prior to, for instance, the `terraform apply` phase. + +Given the following example with the [random provider](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string), we have written a test that asserts that `random_string.one` will be destroyed and re-created when the `length` attribute is changed: + +```go +func Test_Random_EmptyPlan(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + }, + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + }, + }) +} +``` \ No newline at end of file diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks/output.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks/output.mdx new file mode 100644 index 000000000..8e8edd1bd --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks/output.mdx @@ -0,0 +1,87 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Plan Checks' +description: >- + Plan Checks are test assertions that can inspect a plan at different phases in a TestStep. The testing module + provides built-in Output Value Plan Checks for common use-cases. +--- + +# Output Plan Checks + +The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in output value plan checks for common use-cases: + +| Check | Description | +|------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| [`plancheck.ExpectNullOutputValue(address)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNullOutputValue) | Asserts the output at the specified address has a null value. | +| [`plancheck.ExpectNullOutputValueAtPath(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNullOutputValueAtPath) | Asserts the output at the specified address, and path has a null value. | +| [`plancheck.ExpectUnknownOutputValue(address)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownOutputValue) | Asserts the output at the specified address has an unknown value. | +| [`plancheck.ExpectUnknownOutputValueAtPath(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownOutputValueAtPath) | Asserts the output at the specified address, and path has an unknown value. | + +## Example using `plancheck.ExpectUnknownOutputValue` + +One of the built-in plan checks, [`plancheck.ExpectUnknownOutputValue`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownOutputValue), determines whether an output value is unknown, for example, prior to the `terraform apply` phase. + +The following uses the [time_offset](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/offset) resource from the [time provider](https://registry.terraform.io/providers/hashicorp/time/latest), to illustrate usage of the [`plancheck.ExpectUnknownOutputValue`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownOutputValue), and verifies that `day` is unknown. + +```go +func Test_Time_Unknown(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ExternalProviders: map[string]resource.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + Steps: []resource.TestStep{ + { + Config: `resource "time_offset" "one" { + offset_days = 1 + } + + output day { + value = time_offset.one.day + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValue("day"), + }, + }, + }, + }, + }) +} +``` + +## Example using `plancheck.ExpectUnknownOutputValueAtPath` + +Output values can contain objects or collections as well as primitive (e.g., string) values. Output value plan checks provide two forms for the plan checks, for example `ExpectUnknownOutputValue()`, and `ExpectUnknownOutputValueAtPath()`. The `Expect<...>OutputValueAtPath()` form is used to access a value contained within an object or collection, as illustrated in the following example. + +```go +func Test_Time_Unknown(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + ExternalProviders: map[string]resource.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + Steps: []resource.TestStep{ + { + Config: `resource "time_offset" "one" { + offset_days = 1 + } + + output time_offset_one { + value = time_offset.one + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownOutputValueAtPath("time_offset_one", tfjsonpath.New("day")), + }, + }, + }, + }, + }) +} +``` diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks/resource.mdx similarity index 51% rename from website/docs/plugin/testing/acceptance-tests/plan-checks.mdx rename to website/docs/plugin/testing/acceptance-tests/plan-checks/resource.mdx index fa6a93b38..6e197e043 100644 --- a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks/resource.mdx @@ -2,34 +2,20 @@ page_title: 'Plugin Development - Acceptance Testing: Plan Checks' description: >- Plan Checks are test assertions that can inspect a plan at different phases in a TestStep. The testing module - provides built-in Plan Checks for common use-cases, but custom Plan Checks can also be implemented. + provides built-in Managed Resource and Data Source Plan Checks for common use-cases. --- -# Plan Checks +# Resource Plan Checks -During the **Lifecycle (config)** and **Refresh** [modes](/terraform/plugin/testing/acceptance-tests/teststep#test-modes) of a `TestStep`, the testing framework will run `terraform plan` before and after certain operations. For example, the **Lifecycle (config)** mode will run a plan before the `terraform apply` phase, as well as a plan before and after the `terraform refresh` phase. +The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in managed resource, and data source plan checks for common use-cases: -These `terraform plan` operations results in a [plan file](/terraform/cli/commands/plan#out-filename) and can be represented by this [JSON format](/terraform/internals/json-format#plan-representation). +| Check | Description | +|---------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given managed resource, or data source, has the specified operation for apply. | +| [`plancheck.ExpectUnknownValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownValue) | Asserts the specified attribute at the given managed resource, or data source, has an unknown value. | +| [`plancheck.ExpectSensitiveValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectSensitiveValue) | Asserts the specified attribute at the given managed resource, or data source, has a sensitive value. | -A **plan check** is a test assertion that inspects the plan file at a specific phase during the current testing mode. Multiple plan checks can be run at each defined phase, all assertion errors returned are aggregated, reported as a test failure, and all test cleanup logic is executed. - -- Available plan phases for **Lifecycle (config)** mode are defined in the [`TestStep.ConfigPlanChecks`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/helper/resource#TestStep) struct -- Available plan phases for **Refresh** mode are defined in the [`TestStep.RefreshPlanChecks`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/helper/resource#TestStep) struct -- **Import** mode currently does not run any plan operations, and therefore does not support plan checks. - -## Built-in Plan Checks - -The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in plan checks for common use-cases: - -| Check | Description | -|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| -| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | -| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | -| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given resource has the specified operation for apply. | -| [`plancheck.ExpectUnknownValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownValue) | Asserts the specified attribute at the given resource has an unknown value. | -| [`plancheck.ExpectSensitiveValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectSensitiveValue) | Asserts the specified attribute at the given resource has a sensitive value. | - -### Examples using `plancheck.ExpectResourceAction` +## Examples using `plancheck.ExpectResourceAction` One of the built-in plan checks, [`plancheck.ExpectResourceAction`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction), is useful for determining the exact action type a resource will under-go during, say, the `terraform apply` phase. @@ -164,85 +150,3 @@ func Test_Time_UpdateInPlace_and_NoOp(t *testing.T) { }) } ``` - -## Custom Plan Checks - -The package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) also provides the [`PlanCheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#PlanCheck) interface, which can be implemented for a custom plan check. - -The [`plancheck.CheckPlanRequest`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#CheckPlanRequest) contains the current plan file, parsed by the [terraform-json package](https://pkg.go.dev/github.com/hashicorp/terraform-json#Plan). - -Here is an example implementation of a plan check that asserts that every resource change is a no-op, aka, an empty plan: -```go -package example_test - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-testing/plancheck" -) - -var _ plancheck.PlanCheck = expectEmptyPlan{} - -type expectEmptyPlan struct{} - -func (e expectEmptyPlan) CheckPlan(ctx context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) { - var result error - - for _, rc := range req.Plan.ResourceChanges { - if !rc.Change.Actions.NoOp() { - result = errors.Join(result, fmt.Errorf("expected empty plan, but %s has planned action(s): %v", rc.Address, rc.Change.Actions)) - } - } - - resp.Error = result -} - -func ExpectEmptyPlan() plancheck.PlanCheck { - return expectEmptyPlan{} -} -``` - -And example usage: -```go -package example_test - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/plancheck" -) - -func Test_CustomPlanCheck_ExpectEmptyPlan(t *testing.T) { - t.Parallel() - - resource.Test(t, resource.TestCase{ - ExternalProviders: map[string]resource.ExternalProvider{ - "random": { - Source: "registry.terraform.io/hashicorp/random", - }, - }, - Steps: []resource.TestStep{ - { - Config: `resource "random_string" "one" { - length = 16 - }`, - }, - { - Config: `resource "random_string" "one" { - length = 16 - }`, - ConfigPlanChecks: resource.ConfigPlanChecks{ - PreApply: []plancheck.PlanCheck{ - ExpectEmptyPlan(), - }, - }, - }, - }, - }) -} -```