diff --git a/.changes/unreleased/ENHANCEMENTS-20230321-174020.yaml b/.changes/unreleased/ENHANCEMENTS-20230321-174020.yaml new file mode 100644 index 000000000..b8411d38c --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20230321-174020.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: 'helper/resource: Added plan check functionality to config and refresh modes + with new fields `TestStep.ConfigPlanChecks` and `TestStep.RefreshPlanChecks`' +time: 2023-03-21T17:40:20.521786-04:00 +custom: + Issue: "63" diff --git a/.changes/unreleased/FEATURES-20230321-173506.yaml b/.changes/unreleased/FEATURES-20230321-173506.yaml new file mode 100644 index 000000000..7f2dd9683 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230321-173506.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Introduced new `plancheck` package with interface and built-in plan + check functionality' +time: 2023-03-21T17:35:06.650327-04:00 +custom: + Issue: "63" diff --git a/.changes/unreleased/FEATURES-20230321-173639.yaml b/.changes/unreleased/FEATURES-20230321-173639.yaml new file mode 100644 index 000000000..7d897d92d --- /dev/null +++ b/.changes/unreleased/FEATURES-20230321-173639.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectResourceAction` built-in plan check, which asserts + that a given resource will have a specific resource change type in the plan' +time: 2023-03-21T17:36:39.391477-04:00 +custom: + Issue: "63" diff --git a/.changes/unreleased/FEATURES-20230321-173726.yaml b/.changes/unreleased/FEATURES-20230321-173726.yaml new file mode 100644 index 000000000..e85622f80 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230321-173726.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectEmptyPlan` built-in plan check, which asserts that + there are no resource changes in the plan' +time: 2023-03-21T17:37:26.076041-04:00 +custom: + Issue: "63" diff --git a/.changes/unreleased/FEATURES-20230321-173805.yaml b/.changes/unreleased/FEATURES-20230321-173805.yaml new file mode 100644 index 000000000..2650209fc --- /dev/null +++ b/.changes/unreleased/FEATURES-20230321-173805.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectNonEmptyPlan` built-in plan check, which asserts that + there is at least one resource change in the plan' +time: 2023-03-21T17:38:05.815307-04:00 +custom: + Issue: "63" diff --git a/helper/resource/plan_checks.go b/helper/resource/plan_checks.go new file mode 100644 index 000000000..0a74b0955 --- /dev/null +++ b/helper/resource/plan_checks.go @@ -0,0 +1,29 @@ +package resource + +import ( + "context" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-testing/internal/errorshim" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/mitchellh/go-testing-interface" +) + +func runPlanChecks(ctx context.Context, t testing.T, plan *tfjson.Plan, planChecks []plancheck.PlanCheck) error { + t.Helper() + + var result error + + for _, planCheck := range planChecks { + resp := plancheck.CheckPlanResponse{} + planCheck.CheckPlan(ctx, plancheck.CheckPlanRequest{Plan: plan}, &resp) + + if resp.Error != nil { + // TODO: Once Go 1.20 is the minimum supported version for this module, replace with `errors.Join` function + // - https://github.com/hashicorp/terraform-plugin-testing/issues/99 + result = errorshim.Join(result, resp.Error) + } + } + + return result +} diff --git a/helper/resource/plan_checks_test.go b/helper/resource/plan_checks_test.go new file mode 100644 index 000000000..9e74e213f --- /dev/null +++ b/helper/resource/plan_checks_test.go @@ -0,0 +1,19 @@ +package resource + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +var _ plancheck.PlanCheck = &planCheckSpy{} + +type planCheckSpy struct { + err error + called bool +} + +func (s *planCheckSpy) CheckPlan(ctx context.Context, req plancheck.CheckPlanRequest, resp *plancheck.CheckPlanResponse) { + s.called = true + resp.Error = s.err +} diff --git a/helper/resource/testing.go b/helper/resource/testing.go index 0d479d8d6..55aee5a53 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-plugin-testing/internal/addrs" @@ -510,6 +511,20 @@ type TestStep struct { // test to pass. ExpectError *regexp.Regexp + // ConfigPlanChecks allows assertions to be made against the plan file at different points of a Config (apply) test using a plan check. + // Custom plan checks can be created by implementing the [PlanCheck] interface, or by using a PlanCheck implementation from the provided [plancheck] package + // + // [PlanCheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#PlanCheck + // [plancheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck + ConfigPlanChecks ConfigPlanChecks + + // RefreshPlanChecks allows assertions to be made against the plan file at different points of a Refresh test using a plan check. + // Custom plan checks can be created by implementing the [PlanCheck] interface, or by using a PlanCheck implementation from the provided [plancheck] package + // + // [PlanCheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#PlanCheck + // [plancheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck + RefreshPlanChecks RefreshPlanChecks + // PlanOnly can be set to only run `plan` with this configuration, and not // actually apply it. This is useful for ensuring config changes result in // no-op plans @@ -679,6 +694,28 @@ type TestStep struct { ExternalProviders map[string]ExternalProvider } +// ConfigPlanChecks defines the different points in a Config TestStep when plan checks can be run. +type ConfigPlanChecks struct { + // PreApply runs all plan checks in the slice. This occurs before the apply of a Config test is run. This slice cannot be populated + // with TestStep.PlanOnly, as there is no PreApply plan run with that flag set. All errors by plan checks in this slice are aggregated, reported, and will result in a test failure. + PreApply []plancheck.PlanCheck + + // PostApplyPreRefresh runs all plan checks in the slice. This occurs after the apply and before the refresh of a Config test is run. + // All errors by plan checks in this slice are aggregated, reported, and will result in a test failure. + PostApplyPreRefresh []plancheck.PlanCheck + + // PostApplyPostRefresh runs all plan checks in the slice. This occurs after the apply and refresh of a Config test are run. + // All errors by plan checks in this slice are aggregated, reported, and will result in a test failure. + PostApplyPostRefresh []plancheck.PlanCheck +} + +// RefreshPlanChecks defines the different points in a Refresh TestStep when plan checks can be run. +type RefreshPlanChecks struct { + // PostRefresh runs all plan checks in the slice. This occurs after the refresh of the Refresh test is run. + // All errors by plan checks in this slice are aggregated, reported, and will result in a test failure. + PostRefresh []plancheck.PlanCheck +} + // ParallelTest performs an acceptance test on a resource, allowing concurrency // with other ParallelTest. The number of concurrent tests is controlled by the // "go test" command -parallel flag. diff --git a/helper/resource/testing_new_config.go b/helper/resource/testing_new_config.go index 3ad3f0916..add09bcc7 100644 --- a/helper/resource/testing_new_config.go +++ b/helper/resource/testing_new_config.go @@ -51,6 +51,24 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return fmt.Errorf("Error running pre-apply plan: %w", err) } + // Run pre-apply plan checks + if len(step.ConfigPlanChecks.PreApply) > 0 { + var plan *tfjson.Plan + err = runProviderCommand(ctx, t, func() error { + var err error + plan, err = wd.SavedPlan(ctx) + return err + }, wd, providers) + if err != nil { + return fmt.Errorf("Error retrieving pre-apply plan: %w", err) + } + + err = runPlanChecks(ctx, t, plan, step.ConfigPlanChecks.PreApply) + if err != nil { + return fmt.Errorf("Pre-apply plan check(s) failed:\n%w", err) + } + } + // We need to keep a copy of the state prior to destroying such // that the destroy steps can verify their behavior in the // check function @@ -131,6 +149,14 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return fmt.Errorf("Error retrieving post-apply plan: %w", err) } + // Run post-apply, pre-refresh plan checks + if len(step.ConfigPlanChecks.PostApplyPreRefresh) > 0 { + err = runPlanChecks(ctx, t, plan, step.ConfigPlanChecks.PostApplyPreRefresh) + if err != nil { + return fmt.Errorf("Post-apply, pre-refresh plan check(s) failed:\n%w", err) + } + } + if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { var stdout string err = runProviderCommand(ctx, t, func() error { @@ -174,6 +200,14 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint return fmt.Errorf("Error retrieving second post-apply plan: %w", err) } + // Run post-apply, post-refresh plan checks + if len(step.ConfigPlanChecks.PostApplyPostRefresh) > 0 { + err = runPlanChecks(ctx, t, plan, step.ConfigPlanChecks.PostApplyPostRefresh) + if err != nil { + return fmt.Errorf("Post-apply, post-refresh plan check(s) failed:\n%w", err) + } + } + // check if plan is empty if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { var stdout string diff --git a/helper/resource/testing_new_config_test.go b/helper/resource/testing_new_config_test.go index 278cebf7e..3276b444b 100644 --- a/helper/resource/testing_new_config_test.go +++ b/helper/resource/testing_new_config_test.go @@ -1,8 +1,11 @@ package resource import ( + "errors" "regexp" "testing" + + "github.com/hashicorp/terraform-plugin-testing/plancheck" ) func TestTest_TestStep_ExpectError_NewConfig(t *testing.T) { @@ -26,3 +29,210 @@ func TestTest_TestStep_ExpectError_NewConfig(t *testing.T) { }, }) } + +func Test_ConfigPlanChecks_PreApply_Called(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{} + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + spy1, + spy2, + }, + }, + }, + }, + }) + + if !spy1.called { + t.Error("expected ConfigPlanChecks.PreApply spy1 to be called at least once") + } + + if !spy2.called { + t.Error("expected ConfigPlanChecks.PreApply spy2 to be called at least once") + } +} + +func Test_ConfigPlanChecks_PreApply_Errors(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{ + err: errors.New("spy2 check failed"), + } + spy3 := &planCheckSpy{ + err: errors.New("spy3 check failed"), + } + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + spy1, + spy2, + spy3, + }, + }, + ExpectError: regexp.MustCompile(`.*?(spy2 check failed)\n.*?(spy3 check failed)`), + }, + }, + }) +} + +func Test_ConfigPlanChecks_PostApplyPreRefresh_Called(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{} + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + }, + }, + }, + }, + }) + + if !spy1.called { + t.Error("expected ConfigPlanChecks.PostApplyPreRefresh spy1 to be called at least once") + } + + if !spy2.called { + t.Error("expected ConfigPlanChecks.PostApplyPreRefresh spy2 to be called at least once") + } +} + +func Test_ConfigPlanChecks_PostApplyPreRefresh_Errors(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{ + err: errors.New("spy2 check failed"), + } + spy3 := &planCheckSpy{ + err: errors.New("spy3 check failed"), + } + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + spy3, + }, + }, + ExpectError: regexp.MustCompile(`.*?(spy2 check failed)\n.*?(spy3 check failed)`), + }, + }, + }) +} + +func Test_ConfigPlanChecks_PostApplyPostRefresh_Called(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{} + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + }, + }, + }, + }, + }) + + if !spy1.called { + t.Error("expected ConfigPlanChecks.PostApplyPostRefresh spy1 to be called at least once") + } + + if !spy2.called { + t.Error("expected ConfigPlanChecks.PostApplyPostRefresh spy2 to be called at least once") + } +} + +func Test_ConfigPlanChecks_PostApplyPostRefresh_Errors(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{ + err: errors.New("spy2 check failed"), + } + spy3 := &planCheckSpy{ + err: errors.New("spy3 check failed"), + } + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + spy3, + }, + }, + ExpectError: regexp.MustCompile(`.*?(spy2 check failed)\n.*?(spy3 check failed)`), + }, + }, + }) +} diff --git a/helper/resource/testing_new_refresh_state.go b/helper/resource/testing_new_refresh_state.go index fe7b28145..86073b165 100644 --- a/helper/resource/testing_new_refresh_state.go +++ b/helper/resource/testing_new_refresh_state.go @@ -67,7 +67,7 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo return wd.CreatePlan(ctx) }, wd, providers) if err != nil { - return fmt.Errorf("Error running post-apply plan: %w", err) + return fmt.Errorf("Error running post-refresh plan: %w", err) } var plan *tfjson.Plan @@ -77,7 +77,15 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo return err }, wd, providers) if err != nil { - return fmt.Errorf("Error retrieving post-apply plan: %w", err) + return fmt.Errorf("Error retrieving post-refresh plan: %w", err) + } + + // Run post-refresh plan checks + if len(step.RefreshPlanChecks.PostRefresh) > 0 { + err = runPlanChecks(ctx, t, plan, step.RefreshPlanChecks.PostRefresh) + if err != nil { + return fmt.Errorf("Post-refresh plan check(s) failed:\n%w", err) + } } if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { diff --git a/helper/resource/testing_new_refresh_state_test.go b/helper/resource/testing_new_refresh_state_test.go new file mode 100644 index 000000000..afc34c9f1 --- /dev/null +++ b/helper/resource/testing_new_refresh_state_test.go @@ -0,0 +1,84 @@ +package resource + +import ( + "errors" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_RefreshPlanChecks_PostRefresh_Called(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{} + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + }, + { + RefreshState: true, + RefreshPlanChecks: RefreshPlanChecks{ + PostRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + }, + }, + }, + }, + }) + + if !spy1.called { + t.Error("expected RefreshPlanChecks.PostRefresh spy1 to be called at least once") + } + + if !spy2.called { + t.Error("expected RefreshPlanChecks.PostRefresh spy2 to be called at least once") + } +} + +func Test_RefreshPlanChecks_PostRefresh_Errors(t *testing.T) { + t.Parallel() + + spy1 := &planCheckSpy{} + spy2 := &planCheckSpy{ + err: errors.New("spy2 check failed"), + } + spy3 := &planCheckSpy{ + err: errors.New("spy3 check failed"), + } + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 16 + }`, + }, + { + RefreshState: true, + RefreshPlanChecks: RefreshPlanChecks{ + PostRefresh: []plancheck.PlanCheck{ + spy1, + spy2, + spy3, + }, + }, + ExpectError: regexp.MustCompile(`.*?(spy2 check failed)\n.*?(spy3 check failed)`), + }, + }, + }) +} diff --git a/helper/resource/teststep_validate.go b/helper/resource/teststep_validate.go index 082d9e6dd..b1da44ad4 100644 --- a/helper/resource/teststep_validate.go +++ b/helper/resource/teststep_validate.go @@ -59,6 +59,9 @@ func (s TestStep) hasProviders(_ context.Context) bool { // - No overlapping ExternalProviders and ProviderFactories entries // - ResourceName is not empty when ImportState is true, ImportStateIdFunc // is not set, and ImportStateId is not set. +// - ConfigPlanChecks (PreApply, PostApplyPreRefresh, PostApplyPostRefresh) are only set when Config is set. +// - ConfigPlanChecks.PreApply are only set when PlanOnly is false. +// - RefreshPlanChecks (PostRefresh) are only set when RefreshState is set. func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) error { ctx = logging.TestStepNumberContext(ctx, req.StepNumber) @@ -124,5 +127,37 @@ func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) err } } + if len(s.ConfigPlanChecks.PreApply) > 0 { + if s.Config == "" { + err := fmt.Errorf("TestStep ConfigPlanChecks.PreApply must only be specified with Config") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.PlanOnly { + err := fmt.Errorf("TestStep ConfigPlanChecks.PreApply cannot be run with PlanOnly") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + } + + if len(s.ConfigPlanChecks.PostApplyPreRefresh) > 0 && s.Config == "" { + err := fmt.Errorf("TestStep ConfigPlanChecks.PostApplyPreRefresh must only be specified with Config") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if len(s.ConfigPlanChecks.PostApplyPostRefresh) > 0 && s.Config == "" { + err := fmt.Errorf("TestStep ConfigPlanChecks.PostApplyPostRefresh must only be specified with Config") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if len(s.RefreshPlanChecks.PostRefresh) > 0 && !s.RefreshState { + err := fmt.Errorf("TestStep RefreshPlanChecks.PostRefresh must only be specified with RefreshState") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + return nil } diff --git a/helper/resource/teststep_validate_test.go b/helper/resource/teststep_validate_test.go index 051da1d21..cf910c576 100644 --- a/helper/resource/teststep_validate_test.go +++ b/helper/resource/teststep_validate_test.go @@ -5,12 +5,14 @@ package resource import ( "context" + "errors" "fmt" "strings" "testing" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -190,6 +192,57 @@ func TestTestStepValidate(t *testing.T) { }, expectedError: fmt.Errorf("Providers must only be specified either at the TestCase or TestStep level"), }, + "configplanchecks-preapply-not-config-mode": { + testStep: TestStep{ + ConfigPlanChecks: ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{&planCheckSpy{}}, + }, + RefreshState: true, + }, + testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, + expectedError: errors.New("TestStep ConfigPlanChecks.PreApply must only be specified with Config"), + }, + "configplanchecks-preapply-not-planonly": { + testStep: TestStep{ + ConfigPlanChecks: ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{&planCheckSpy{}}, + }, + Config: "# not empty", + PlanOnly: true, + }, + testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, + expectedError: errors.New("TestStep ConfigPlanChecks.PreApply cannot be run with PlanOnly"), + }, + "configplanchecks-postapplyprerefresh-not-config-mode": { + testStep: TestStep{ + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{&planCheckSpy{}}, + }, + RefreshState: true, + }, + testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, + expectedError: errors.New("TestStep ConfigPlanChecks.PostApplyPreRefresh must only be specified with Config"), + }, + "configplanchecks-postapplypostrefresh-not-config-mode": { + testStep: TestStep{ + ConfigPlanChecks: ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{&planCheckSpy{}}, + }, + RefreshState: true, + }, + testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, + expectedError: errors.New("TestStep ConfigPlanChecks.PostApplyPostRefresh must only be specified with Config"), + }, + "refreshplanchecks-postrefresh-not-refresh-mode": { + testStep: TestStep{ + RefreshPlanChecks: RefreshPlanChecks{ + PostRefresh: []plancheck.PlanCheck{&planCheckSpy{}}, + }, + Config: "# not empty", + }, + testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, + expectedError: errors.New("TestStep RefreshPlanChecks.PostRefresh must only be specified with RefreshState"), + }, } for name, test := range tests { diff --git a/internal/errorshim/error_join_shim.go b/internal/errorshim/error_join_shim.go new file mode 100644 index 000000000..06a6314fc --- /dev/null +++ b/internal/errorshim/error_join_shim.go @@ -0,0 +1,44 @@ +// TODO: Once Go 1.20 is the minimum supported version delete this package, replace all usages with `errors` package +// - https://github.com/hashicorp/terraform-plugin-testing/issues/99 +package errorshim + +// Copied from -> https://cs.opensource.google/go/go/+/refs/tags/go1.20.2:src/errors/join.go +func Join(errs ...error) error { + n := 0 + for _, err := range errs { + if err != nil { + n++ + } + } + if n == 0 { + return nil + } + e := &joinError{ + errs: make([]error, 0, n), + } + for _, err := range errs { + if err != nil { + e.errs = append(e.errs, err) + } + } + return e +} + +type joinError struct { + errs []error +} + +func (e *joinError) Error() string { + var b []byte + for i, err := range e.errs { + if i > 0 { + b = append(b, '\n') + } + b = append(b, err.Error()...) + } + return string(b) +} + +func (e *joinError) Unwrap() []error { + return e.errs +} diff --git a/plancheck/doc.go b/plancheck/doc.go new file mode 100644 index 000000000..5f50334cf --- /dev/null +++ b/plancheck/doc.go @@ -0,0 +1,2 @@ +// Package plancheck contains the plan check interface, request/response structs, and common plan check implementations. +package plancheck diff --git a/plancheck/expect_empty_plan.go b/plancheck/expect_empty_plan.go new file mode 100644 index 000000000..23f03e826 --- /dev/null +++ b/plancheck/expect_empty_plan.go @@ -0,0 +1,33 @@ +package plancheck + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/internal/errorshim" +) + +var _ PlanCheck = expectEmptyPlan{} + +type expectEmptyPlan struct{} + +// CheckPlan implements the plan check logic. +func (e expectEmptyPlan) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var result error + + for _, rc := range req.Plan.ResourceChanges { + if !rc.Change.Actions.NoOp() { + // TODO: Once Go 1.20 is the minimum supported version for this module, replace with `errors.Join` function + // - https://github.com/hashicorp/terraform-plugin-testing/issues/99 + result = errorshim.Join(result, fmt.Errorf("expected empty plan, but %s has planned action(s): %v", rc.Address, rc.Change.Actions)) + } + } + + resp.Error = result +} + +// ExpectEmptyPlan returns a plan check that asserts that there are no resource changes in the plan. +// All resource changes found will be aggregated and returned in a plan check error. +func ExpectEmptyPlan() PlanCheck { + return expectEmptyPlan{} +} diff --git a/plancheck/expect_empty_plan_test.go b/plancheck/expect_empty_plan_test.go new file mode 100644 index 000000000..ddc1a4b25 --- /dev/null +++ b/plancheck/expect_empty_plan_test.go @@ -0,0 +1,80 @@ +package plancheck_test + +import ( + "regexp" + "testing" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_ExpectEmptyPlan(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(), + }, + }, + }, + }, + }) +} + +func Test_ExpectEmptyPlan_Error(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 + } + resource "random_string" "two" { + length = 16 + } + resource "random_string" "three" { + length = 16 + }`, + }, + { + Config: `resource "random_string" "one" { + length = 12 + } + resource "random_string" "two" { + length = 16 + } + resource "random_string" "three" { + length = 12 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + ExpectError: regexp.MustCompile(`.*?(random_string.one has planned action\(s\): \[delete create\])\n.*?(random_string.three has planned action\(s\): \[delete create\])`), + }, + }, + }) +} diff --git a/plancheck/expect_non_empty_plan.go b/plancheck/expect_non_empty_plan.go new file mode 100644 index 000000000..1a6206b65 --- /dev/null +++ b/plancheck/expect_non_empty_plan.go @@ -0,0 +1,26 @@ +package plancheck + +import ( + "context" + "errors" +) + +var _ PlanCheck = expectNonEmptyPlan{} + +type expectNonEmptyPlan struct{} + +// CheckPlan implements the plan check logic. +func (e expectNonEmptyPlan) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + for _, rc := range req.Plan.ResourceChanges { + if !rc.Change.Actions.NoOp() { + return + } + } + + resp.Error = errors.New("expected a non-empty plan, but got an empty plan") +} + +// ExpectNonEmptyPlan returns a plan check that asserts there is at least one resource change in the plan. +func ExpectNonEmptyPlan() PlanCheck { + return expectNonEmptyPlan{} +} diff --git a/plancheck/expect_non_empty_plan_test.go b/plancheck/expect_non_empty_plan_test.go new file mode 100644 index 000000000..5277591ea --- /dev/null +++ b/plancheck/expect_non_empty_plan_test.go @@ -0,0 +1,68 @@ +package plancheck_test + +import ( + "regexp" + "testing" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_ExpectNonEmptyPlan(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 = 12 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNonEmptyPlan(), + }, + }, + }, + }, + }) +} + +func Test_ExpectNonEmptyPlan_Error(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.ExpectNonEmptyPlan(), + }, + }, + ExpectError: regexp.MustCompile(`expected a non-empty plan, but got an empty plan`), + }, + }, + }) +} diff --git a/plancheck/expect_resource_action.go b/plancheck/expect_resource_action.go new file mode 100644 index 000000000..6d8d16607 --- /dev/null +++ b/plancheck/expect_resource_action.go @@ -0,0 +1,87 @@ +package plancheck + +import ( + "context" + "fmt" +) + +var _ PlanCheck = expectResourceAction{} + +type expectResourceAction struct { + resourceAddress string + actionType ResourceActionType +} + +// CheckPlan implements the plan check logic. +func (e expectResourceAction) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + foundResource := false + + for _, rc := range req.Plan.ResourceChanges { + if e.resourceAddress != rc.Address { + continue + } + + switch e.actionType { + case ResourceActionNoop: + if !rc.Change.Actions.NoOp() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionCreate: + if !rc.Change.Actions.Create() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionRead: + if !rc.Change.Actions.Read() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionUpdate: + if !rc.Change.Actions.Update() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionDestroy: + if !rc.Change.Actions.Delete() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionDestroyBeforeCreate: + if !rc.Change.Actions.DestroyBeforeCreate() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionCreateBeforeDestroy: + if !rc.Change.Actions.CreateBeforeDestroy() { + resp.Error = fmt.Errorf("'%s' - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + case ResourceActionReplace: + if !rc.Change.Actions.Replace() { + resp.Error = fmt.Errorf("%s - expected %s, got action(s): %v", rc.Address, e.actionType, rc.Change.Actions) + return + } + default: + resp.Error = fmt.Errorf("%s - unexpected ResourceActionType: %s", rc.Address, e.actionType) + return + } + + foundResource = true + break + } + + if !foundResource { + resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) + return + } +} + +// ExpectResourceAction returns a plan check that asserts that a given resource will have a specific resource change type in the plan. +// Valid actionType are an enum of type plancheck.ResourceActionType, examples: NoOp, DestroyBeforeCreate, Update (in-place), etc. +func ExpectResourceAction(resourceAddress string, actionType ResourceActionType) PlanCheck { + return expectResourceAction{ + resourceAddress: resourceAddress, + actionType: actionType, + } +} diff --git a/plancheck/expect_resource_action_test.go b/plancheck/expect_resource_action_test.go new file mode 100644 index 000000000..abc7b6333 --- /dev/null +++ b/plancheck/expect_resource_action_test.go @@ -0,0 +1,531 @@ +package plancheck_test + +import ( + "regexp" + "testing" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_ExpectedResourceAction_NoOp(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.ExpectResourceAction("random_string.one", plancheck.ResourceActionNoop), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_NoOp_NoMatch(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionNoop), + }, + }, + ExpectError: regexp.MustCompile(`expected NoOp, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_Create(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionCreate), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_Create_NoMatch(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 = 15 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionCreate), + }, + }, + ExpectError: regexp.MustCompile(`expected Create, got action\(s\): \[delete create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_Read(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + "null": { + Source: "registry.terraform.io/hashicorp/null", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "random_string" "one" { + length = 15 + } + + data "null_data_source" "two" { + inputs = { + unknown_val = random_string.one.result + } + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("data.null_data_source.two", plancheck.ResourceActionRead), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_Read_NoMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + "null": { + Source: "registry.terraform.io/hashicorp/null", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "random_string" "one" { + length = 15 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionRead), + }, + }, + ExpectError: regexp.MustCompile(`expected Read, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_Update(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_offset" "one" { + offset_days = 1 + }`, + }, + { + Config: `resource "time_offset" "one" { + offset_days = 2 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("time_offset.one", plancheck.ResourceActionUpdate), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_Update_NoMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_offset" "one" { + offset_days = 1 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("time_offset.one", plancheck.ResourceActionUpdate), + }, + }, + ExpectError: regexp.MustCompile(`expected Update, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_Destroy(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: ` `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionDestroy), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_Destroy_NoMatch(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionDestroy), + }, + }, + ExpectError: regexp.MustCompile(`expected Destroy, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_DestroyBeforeCreate(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 = 15 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_DestroyBeforeCreate_NoMatch(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + ExpectError: regexp.MustCompile(`expected DestroyBeforeCreate, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_CreateBeforeDestroy(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 + lifecycle { + create_before_destroy = true + } + }`, + }, + { + Config: `resource "random_string" "one" { + length = 15 + lifecycle { + create_before_destroy = true + } + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionCreateBeforeDestroy), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_CreateBeforeDestroy_NoMatch(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionCreateBeforeDestroy), + }, + }, + ExpectError: regexp.MustCompile(`expected CreateBeforeDestroy, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_Replace(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 + } + + resource "random_string" "two" { + length = 16 + lifecycle { + create_before_destroy = true + } + }`, + }, + { + Config: `resource "random_string" "one" { + length = 15 + } + + resource "random_string" "two" { + length = 15 + lifecycle { + create_before_destroy = true + } + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionReplace), + plancheck.ExpectResourceAction("random_string.two", plancheck.ResourceActionReplace), + }, + }, + }, + }, + }) +} + +func Test_ExpectedResourceAction_Replace_NoMatch(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionReplace), + }, + }, + ExpectError: regexp.MustCompile(`expected Replace, got action\(s\): \[create\]`), + }, + { + Config: `resource "random_string" "two" { + length = 16 + lifecycle { + create_before_destroy = true + } + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.two", plancheck.ResourceActionReplace), + }, + }, + ExpectError: regexp.MustCompile(`expected Replace, got action\(s\): \[create\]`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_NoResourceFound(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.doesntexist", plancheck.ResourceActionCreate), + }, + }, + ExpectError: regexp.MustCompile(`random_string.doesntexist - Resource not found in plan ResourceChanges`), + }, + }, + }) +} + +func Test_ExpectedResourceAction_InvalidResourceActionType(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 + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", "Invalid"), + }, + }, + ExpectError: regexp.MustCompile(`random_string.one - unexpected ResourceActionType: Invalid`), + }, + }, + }) +} diff --git a/plancheck/plan_check.go b/plancheck/plan_check.go new file mode 100644 index 000000000..4de90e17c --- /dev/null +++ b/plancheck/plan_check.go @@ -0,0 +1,27 @@ +package plancheck + +import ( + "context" + + tfjson "github.com/hashicorp/terraform-json" +) + +// PlanCheck defines an interface for implementing test logic that checks a plan file and then returns an error +// if the plan file does not match what is expected. +type PlanCheck interface { + // CheckPlan should perform the plan check. + CheckPlan(context.Context, CheckPlanRequest, *CheckPlanResponse) +} + +// CheckPlanRequest is a request for an invoke of the CheckPlan function. +type CheckPlanRequest struct { + // Plan represents a parsed plan file, retrieved via the `terraform show -json` command. + Plan *tfjson.Plan +} + +// CheckPlanResponse is a response to an invoke of the CheckPlan function. +type CheckPlanResponse struct { + // Error is used to report the failure of a plan check assertion and is combined with other PlanCheck errors + // to be reported as a test failure. + Error error +} diff --git a/plancheck/resource_action.go b/plancheck/resource_action.go new file mode 100644 index 000000000..95494ead3 --- /dev/null +++ b/plancheck/resource_action.go @@ -0,0 +1,47 @@ +package plancheck + +// ResourceActionType is a string enum type that routes to a specific terraform-json.Actions function for asserting resource changes. +// - https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions +// +// More information about expected resource behavior can be found at: https://developer.hashicorp.com/terraform/language/resources/behavior +type ResourceActionType string + +const ( + // ResourceActionNoop occurs when a resource is not planned to change (no-op). + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.NoOp + ResourceActionNoop ResourceActionType = "NoOp" + + // ResourceActionCreate occurs when a resource is planned to be created. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.Create + ResourceActionCreate ResourceActionType = "Create" + + // ResourceActionRead occurs when a data source is planned to be read during the apply stage (data sources are read during plan stage when possible). + // See the data source documentation for more information on this behavior: https://developer.hashicorp.com/terraform/language/data-sources#data-resource-behavior + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.Read + ResourceActionRead ResourceActionType = "Read" + + // ResourceActionUpdate occurs when a resource is planned to be updated in-place. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.Update + ResourceActionUpdate ResourceActionType = "Update" + + // ResourceActionDestroy occurs when a resource is planned to be deleted. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.Delete + ResourceActionDestroy ResourceActionType = "Destroy" + + // ResourceActionDestroyBeforeCreate occurs when a resource is planned to be deleted and then re-created. This is the default + // behavior when terraform must change a resource argument that cannot be updated in-place due to remote API limitations. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.DestroyBeforeCreate + ResourceActionDestroyBeforeCreate ResourceActionType = "DestroyBeforeCreate" + + // ResourceActionCreateBeforeDestroy occurs when a resource is planned to be created and then deleted. This is opt-in behavior that + // is enabled with the [create_before_destroy] meta-argument. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.CreateBeforeDestroy + // + // [create_before_destroy]: https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#create_before_destroy + ResourceActionCreateBeforeDestroy ResourceActionType = "CreateBeforeDestroy" + + // ResourceActionReplace can be used to verify a resource is planned to be deleted and re-created (where the order of delete and create actions are not important). + // This action matches both ResourceActionDestroyBeforeCreate and ResourceActionCreateBeforeDestroy. + // - Routes to: https://pkg.go.dev/github.com/hashicorp/terraform-json#Actions.Replace + ResourceActionReplace ResourceActionType = "Replace" +) diff --git a/website/data/plugin-testing-nav-data.json b/website/data/plugin-testing-nav-data.json index f1b263d4c..a3699b093 100644 --- a/website/data/plugin-testing-nav-data.json +++ b/website/data/plugin-testing-nav-data.json @@ -17,6 +17,10 @@ "title": "Test Steps", "path": "acceptance-tests/teststep" }, + { + "title": "Plan Checks", + "path": "acceptance-tests/plan-checks" + }, { "title": "Sweepers", "path": "acceptance-tests/sweepers" diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx new file mode 100644 index 000000000..fa91a402f --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx @@ -0,0 +1,246 @@ +--- +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. +--- + +# 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. + +## 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. | + +### 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. + +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 +package example_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_Random_ForcesRecreate(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 = 15 + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("random_string.one", plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + }, + }, + }) +} +``` + +Another example with the [time provider](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/offset) asserts that `time_offset.one` will be updated in-place when the `offset_days` attribute is changed: + +```go +package example_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_Time_UpdateInPlace(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 + }`, + }, + { + Config: `resource "time_offset" "one" { + offset_days = 2 + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("time_offset.one", plancheck.ResourceActionUpdate), + }, + }, + }, + }, + }) +} +``` + +Multiple plan checks can be combined if you want to assert multiple resource actions: +```go +package example_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func Test_Time_UpdateInPlace_and_NoOp(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 + } + resource "time_offset" "two" { + offset_days = 1 + }`, + }, + { + Config: `resource "time_offset" "one" { + offset_days = 2 + } + resource "time_offset" "two" { + offset_days = 1 + }`, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("time_offset.one", plancheck.ResourceActionUpdate), + plancheck.ExpectResourceAction("time_offset.two", plancheck.ResourceActionNoop), + }, + }, + }, + }, + }) +} +``` + +## 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/teststep.mdx b/website/docs/plugin/testing/acceptance-tests/teststep.mdx index ae23aa446..0c66a9ff7 100644 --- a/website/docs/plugin/testing/acceptance-tests/teststep.mdx +++ b/website/docs/plugin/testing/acceptance-tests/teststep.mdx @@ -14,10 +14,10 @@ under test. ## Test Modes -Terraform’s test framework facilitates three distinct modes of acceptance tests, -_Lifecycle_, _Import_ and _Refresh_. +Terraform's test framework facilitates three distinct modes of acceptance tests, +_Lifecycle (config)_, _Import_ and _Refresh_. -_Lifecycle_ mode is the most common mode, and is used for testing plugins by +_Lifecycle (config)_ mode is the most common mode, and is used for testing plugins by providing one or more configuration files with the same logic as would be used when running `terraform apply`. @@ -29,7 +29,7 @@ _Refresh_ mode is used for testing resource functionality to refresh existing infrastructure, using the same logic as would be used when running `terraform refresh`. -An acceptance test’s mode is implicitly determined by the fields provided in the +An acceptance test's mode is implicitly determined by the fields provided in the `TestStep` definition. The applicable fields are defined in the [TestStep Reference API](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/helper/resource#TestStep). @@ -82,7 +82,7 @@ applying and checking a basic configuration, followed by applying a modified configuration with updated or additional checks is a common pattern used to test update functionality. -## Check Functions +## State Check Functions After the configuration for a `TestStep` is applied, Terraform’s testing framework provides developers an opportunity to check the results by providing a @@ -294,6 +294,9 @@ func testAccCheckExampleWidgetExists(resourceName string, widget *example.Widget } ``` -## Next Steps +## Plan Checks +Before and after the configuration for a `TestStep` is applied, Terraform's testing framework provides developers an opportunity to make test assertions against `terraform plan` results via the plan file. This is provided via [Plan Checks](/terraform/plugin/testing/acceptance-tests/plan-checks), which provide both built-in plan checks and an interface to implement custom plan checks. + +## Sweepers Acceptance Testing is an essential approach to validating the implementation of a Terraform Provider. Using actual APIs to provision resources for testing can leave behind real infrastructure that costs money between tests. The reasons for these leaks can vary, regardless Terraform provides a mechanism known as [Sweepers](/terraform/plugin/testing/acceptance-tests/sweepers) to help keep the testing account clean.