Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

helper/resource: Ensure TestStep.ExpectNonEmptyPlan accounts for output changes #234

Merged
merged 3 commits into from
Dec 1, 2023
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
Loading