Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/BUG FIXES-20231130-102326.yaml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this PR also changes the "default" behavior (TestStep.ExpectNonEmptyPlan = false) of non-empty plan's throwing an error by also checking output changes.

Should we describe that (potentially non-obvious) behavior change in a separate changelog?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call -- adding a second note entry along the lines of:

helper/resource: Configuration based `TestStep` now include post-apply plan checks for `output` changes in addition to resource changes. If this causes unexpected new test failures, most `output` configuration blocks can be likely be removed. Test steps involving resources and data sources should never need to use `output` configuration blocks as plan and state checks support working on resource and data source attributes values directly.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: BUG FIXES
body: 'helper/resource: Ensured `TestStep.ExpectNonEmptyPlan` accounts for output
changes with Terraform 0.14 and later'
time: 2023-11-30T10:23:26.348382-05:00
custom:
Issue: "234"
14 changes: 13 additions & 1 deletion helper/resource/testing_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"

"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-version"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/go-testing-interface"

Expand Down Expand Up @@ -476,14 +477,25 @@ func stateIsEmpty(state *terraform.State) bool {
return state.Empty() || !state.HasResources() //nolint:staticcheck // legacy usage
}

func planIsEmpty(plan *tfjson.Plan) bool {
func planIsEmpty(plan *tfjson.Plan, tfVersion *version.Version) bool {
for _, rc := range plan.ResourceChanges {
for _, a := range rc.Change.Actions {
if a != tfjson.ActionNoop {
return false
}
}
}

if tfVersion.LessThan(expectNonEmptyPlanOutputChangesMinTFVersion) {
return true
}

for _, change := range plan.OutputChanges {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a potentially breaking change for the reasons described in Austin's comment?

if !change.Actions.NoOp() {
return false
}
}

return true
}

Expand Down
12 changes: 9 additions & 3 deletions helper/resource/testing_new_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"

"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/go-testing-interface"
Expand All @@ -20,6 +21,11 @@ import (
"github.com/hashicorp/terraform-plugin-testing/internal/plugintest"
)

// expectNonEmptyPlanOutputChangesMinTFVersion is used to keep compatibility for
// Terraform 0.12 and 0.13 after enabling ExpectNonEmptyPlan to check output
// changes. Those older versions will always show outputs being created.
var expectNonEmptyPlanOutputChangesMinTFVersion = version.Must(version.NewVersion("0.14.0"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var expectNonEmptyPlanOutputChangesMinTFVersion = version.Must(version.NewVersion("0.14.0"))
var expectNonEmptyPlanOutputChangesMinTFVersion = tfversion.Version0_14_0

Nit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call -- sorry couldn't accept suggestion directly as imports also needed updates 👍


func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories, stepIndex int, helper *plugintest.Helper) error {
t.Helper()

Expand Down Expand Up @@ -236,7 +242,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
}
}

if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
if !planIsEmpty(plan, helper.TerraformVersion()) && !step.ExpectNonEmptyPlan {
var stdout string
err = runProviderCommand(ctx, t, func() error {
var err error
Expand Down Expand Up @@ -283,7 +289,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
}

// check if plan is empty
if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
if !planIsEmpty(plan, helper.TerraformVersion()) && !step.ExpectNonEmptyPlan {
var stdout string
err = runProviderCommand(ctx, t, func() error {
var err error
Expand All @@ -294,7 +300,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
return fmt.Errorf("Error retrieving formatted second plan output: %w", err)
}
return fmt.Errorf("After applying this test step and performing a `terraform refresh`, the plan was not empty.\nstdout\n\n%s", stdout)
} else if step.ExpectNonEmptyPlan && planIsEmpty(plan) {
} else if step.ExpectNonEmptyPlan && planIsEmpty(plan, helper.TerraformVersion()) {
return errors.New("Expected a non-empty plan, but got an empty plan")
}

Expand Down
272 changes: 272 additions & 0 deletions helper/resource/testing_new_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,278 @@ func TestTest_TestStep_ExpectError_NewConfig(t *testing.T) {
})
}

func Test_ExpectNonEmptyPlan_EmptyPlanError(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_4_0),
},
ExternalProviders: map[string]ExternalProvider{
"terraform": {Source: "terraform.io/builtin/terraform"},
},
Steps: []TestStep{
{
Config: `resource "terraform_data" "test" {}`,
ExpectNonEmptyPlan: true,
ExpectError: regexp.MustCompile("Expected a non-empty plan, but got an empty plan"),
},
},
})
}

func Test_ExpectNonEmptyPlan_PreRefresh_ResourceChanges(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_4_0),
},
ExternalProviders: map[string]ExternalProvider{
"terraform": {Source: "terraform.io/builtin/terraform"},
},
Steps: []TestStep{
{
Config: `resource "terraform_data" "test" {
# Never recommended for real world configurations, but tests
# the intended behavior.
input = timestamp()
}`,
ConfigPlanChecks: ConfigPlanChecks{
// Verification of that the behavior is being caught pre
// refresh. We want to ensure ExpectNonEmptyPlan allows test
// to pass if pre refresh also has changes.
PostApplyPreRefresh: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("terraform_data.test", plancheck.ResourceActionUpdate),
},
},
ExpectNonEmptyPlan: true,
},
},
})
}

func Test_ExpectNonEmptyPlan_PostRefresh_OutputChanges(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipAbove(tfversion.Version0_14_0), // outputs before 0.14 always show as created
},
// Avoid our own validation that requires at least one provider config.
ExternalProviders: map[string]ExternalProvider{
"terraform": {Source: "terraform.io/builtin/terraform"},
},
Steps: []TestStep{
{
Config: `output "test" { value = timestamp() }`,
ExpectNonEmptyPlan: false, // compatibility compromise for 0.12 and 0.13
},
},
})

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version0_14_0), // outputs before 0.14 always show as created
},
// Avoid our own validation that requires at least one provider config.
ExternalProviders: map[string]ExternalProvider{
"terraform": {Source: "terraform.io/builtin/terraform"},
},
Steps: []TestStep{
{
Config: `output "test" { value = timestamp() }`,
ExpectNonEmptyPlan: true,
},
},
})
}

func Test_ExpectNonEmptyPlan_PostRefresh_ResourceChanges(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"test": providerserver.NewProviderServer(testprovider.Provider{
Resources: map[string]testprovider.Resource{
"test_resource": {
CreateResponse: &resource.CreateResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "test"), // intentionally same
},
),
},
ReadResponse: &resource.ReadResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "not-test"), // intentionally different
},
),
},
SchemaResponse: &resource.SchemaResponse{
Schema: &tfprotov6.Schema{
Block: &tfprotov6.SchemaBlock{
Attributes: []*tfprotov6.SchemaAttribute{
{
Name: "id",
Type: tftypes.String,
Required: true,
},
},
},
},
},
},
},
}),
},
Steps: []TestStep{
{
Config: `resource "test_resource" "test" {
# Post create refresh intentionally changes configured value
# which is an errant resource implementation. Create should
# account for the correct post creation state, preventing an
# immediate difference next Terraform run for practitioners.
# This errant resource behavior verifies the expected
# behavior of ExpectNonEmptyPlan for post refresh planning.
id = "test"
}`,
ConfigPlanChecks: ConfigPlanChecks{
// Verification of that the behavior is being caught post
// refresh. We want to ensure ExpectNonEmptyPlan is being
// triggered after the pre refresh plan shows no changes.
PostApplyPreRefresh: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("test_resource.test", plancheck.ResourceActionNoop),
},
},
ExpectNonEmptyPlan: true,
},
},
})
}

func Test_NonEmptyPlan_PreRefresh_Error(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_4_0),
},
ExternalProviders: map[string]ExternalProvider{
"terraform": {Source: "terraform.io/builtin/terraform"},
},
Steps: []TestStep{
{
Config: `resource "terraform_data" "test" {
# Never recommended for real world configurations, but tests
# the intended behavior.
input = timestamp()
}`,
ConfigPlanChecks: ConfigPlanChecks{
// Verification of that the behavior is being caught pre
// refresh.
PostApplyPreRefresh: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("terraform_data.test", plancheck.ResourceActionUpdate),
},
},
ExpectNonEmptyPlan: false, // intentional
ExpectError: regexp.MustCompile("After applying this test step, the plan was not empty."),
},
},
})
}

func Test_NonEmptyPlan_PostRefresh_Error(t *testing.T) {
t.Parallel()

UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"test": providerserver.NewProviderServer(testprovider.Provider{
Resources: map[string]testprovider.Resource{
"test_resource": {
CreateResponse: &resource.CreateResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "test"), // intentionally same
},
),
},
ReadResponse: &resource.ReadResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "not-test"), // intentionally different
},
),
},
SchemaResponse: &resource.SchemaResponse{
Schema: &tfprotov6.Schema{
Block: &tfprotov6.SchemaBlock{
Attributes: []*tfprotov6.SchemaAttribute{
{
Name: "id",
Type: tftypes.String,
Required: true,
},
},
},
},
},
},
},
}),
},
Steps: []TestStep{
{
Config: `resource "test_resource" "test" {
# Post create refresh intentionally changes configured value
# which is an errant resource implementation. Create should
# account for the correct post creation state, preventing an
# immediate difference next Terraform run for practitioners.
# This errant resource behavior verifies the expected
# behavior of ExpectNonEmptyPlan for post refresh planning.
id = "test"
}`,
ConfigPlanChecks: ConfigPlanChecks{
// Verification of that the behavior is being caught post
// refresh.
PostApplyPreRefresh: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("test_resource.test", plancheck.ResourceActionNoop),
},
},
ExpectNonEmptyPlan: false, // intentional
ExpectError: regexp.MustCompile("After applying this test step and performing a `terraform refresh`, the plan was not empty."),
},
},
})
}

func Test_ConfigPlanChecks_PreApply_Called(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 1 addition & 1 deletion helper/resource/testing_new_refresh_state.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to add a test for this 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added positive and negative tests for RefreshState: true and ExpectNonEmptyPlan 👍

func Test_RefreshState_ExpectNonEmptyPlan(t *testing.T) {
	t.Parallel()

	UnitTest(t, TestCase{
		TerraformVersionChecks: []tfversion.TerraformVersionCheck{
			tfversion.SkipBelow(tfversion.Version1_4_0),
		},
		ExternalProviders: map[string]ExternalProvider{
			"terraform": {Source: "terraform.io/builtin/terraform"},
		},
		Steps: []TestStep{
			{
				Config: `resource "terraform_data" "test" {
					# Never recommended for real world configurations, but tests
					# the intended behavior.
					input = timestamp()
				}`,
				ExpectNonEmptyPlan: false, // intentional
				ExpectError:        regexp.MustCompile("After applying this test step, the plan was not empty."),
			},
			{
				RefreshState:       true,
				ExpectNonEmptyPlan: true,
			},
		},
	})
}

func Test_RefreshState_NonEmptyPlan_Error(t *testing.T) {
	t.Parallel()

	UnitTest(t, TestCase{
		TerraformVersionChecks: []tfversion.TerraformVersionCheck{
			tfversion.SkipBelow(tfversion.Version1_4_0),
		},
		ExternalProviders: map[string]ExternalProvider{
			"terraform": {Source: "terraform.io/builtin/terraform"},
		},
		Steps: []TestStep{
			{
				Config: `resource "terraform_data" "test" {
					# Never recommended for real world configurations, but tests
					# the intended behavior.
					input = timestamp()
				}`,
				ExpectNonEmptyPlan: false, // intentional
				ExpectError:        regexp.MustCompile("After applying this test step, the plan was not empty."),
			},
			{
				RefreshState:       true,
				ExpectNonEmptyPlan: false, // intentional
				ExpectError:        regexp.MustCompile("After refreshing state during this test step, a followup plan was not empty."),
			},
		},
	})
}

Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo
}
}

if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
if !planIsEmpty(plan, wd.GetHelper().TerraformVersion()) && !step.ExpectNonEmptyPlan {
var stdout string
err = runProviderCommand(ctx, t, func() error {
var err error
Expand Down