diff --git a/.changes/unreleased/ENHANCEMENTS-20230714-065551.yaml b/.changes/unreleased/ENHANCEMENTS-20230714-065551.yaml new file mode 100644 index 000000000..3b99bfd55 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20230714-065551.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: Added `RemoveState` - new testing mode, which allows to delete listed resources + from state, to prevent their automatic destroying. +time: 2023-07-14T06:55:51.777536+02:00 +custom: + Issue: "85" diff --git a/helper/resource/testing.go b/helper/resource/testing.go index d1dcc3406..c78368db5 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -484,6 +484,20 @@ type TestStep struct { // to run based on what settings below are set. //--------------------------------------------------------------- + //--------------------------------------------------------------- + // RemoveState testing + //--------------------------------------------------------------- + + // RemoveState is a list of resource addresses to be removed from state after + // applying config. Be sure to only include this at a step where the referenced + // address will be present in state, as it will fail the test if the resource + // is missing. + // + // Usage: + // - RemoveState should not be present in the same TestStep as Config: "not empty", ImportState or RefreshState. + // - RemoveState should not be present in the first TestStep similar to RefreshState. + RemoveState []string + //--------------------------------------------------------------- // Plan, Apply testing //--------------------------------------------------------------- diff --git a/helper/resource/testing_new.go b/helper/resource/testing_new.go index da4785f0a..1456a881e 100644 --- a/helper/resource/testing_new.go +++ b/helper/resource/testing_new.go @@ -333,6 +333,49 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest continue } + if len(step.RemoveState) > 0 { + logging.HelperResourceTrace(ctx, "TestStep is RemoveState mode") + + err := testStepRemoveState(ctx, step, wd) + + if step.ExpectError != nil { + logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError") + + if err == nil { + logging.HelperResourceError(ctx, + "Expected an error but got none", + ) + t.Fatalf("Step %d/%d, expected an error but got none", stepNumber, len(c.Steps)) + } + if !step.ExpectError.MatchString(err.Error()) { + logging.HelperResourceError(ctx, + fmt.Sprintf("Expected an error with pattern (%s)", step.ExpectError.String()), + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d, expected an error with pattern, no match on: %s", stepNumber, len(c.Steps), err) + } + } else { + if err != nil && c.ErrorCheck != nil { + logging.HelperResourceDebug(ctx, "Calling TestCase ErrorCheck") + + err = c.ErrorCheck(err) + + logging.HelperResourceDebug(ctx, "Called TestCase ErrorCheck") + } + if err != nil { + logging.HelperResourceError(ctx, + "Unexpected error", + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d error: %s", stepNumber, len(c.Steps), err) + } + } + + logging.HelperResourceDebug(ctx, "Finished TestStep") + + continue + } + t.Fatalf("Step %d/%d, unsupported test mode", stepNumber, len(c.Steps)) } diff --git a/helper/resource/testing_new_remove_state.go b/helper/resource/testing_new_remove_state.go new file mode 100644 index 000000000..026936cfb --- /dev/null +++ b/helper/resource/testing_new_remove_state.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/internal/logging" + + "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" +) + +func testStepRemoveState(ctx context.Context, step TestStep, wd *plugintest.WorkingDir) error { + if len(step.RemoveState) == 0 { + return nil + } + + logging.HelperResourceTrace(ctx, fmt.Sprintf("Using TestStep RemoveState: %v", step.RemoveState)) + + for _, p := range step.RemoveState { + err := wd.RemoveState(ctx, p) + if err != nil { + return fmt.Errorf("error remove state resource: %s", err) + } + } + return nil +} diff --git a/helper/resource/testing_new_remove_state_test.go b/helper/resource/testing_new_remove_state_test.go new file mode 100644 index 000000000..87ae19aa6 --- /dev/null +++ b/helper/resource/testing_new_remove_state_test.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "regexp" + "testing" +) + +func Test_RemoveState_Ok(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 2 + }`, + Check: TestCheckResourceAttr("random_string.one", "length", "2"), + }, + { + RemoveState: []string{"random_string.one"}, + Check: TestCheckNoResourceAttr("random_string.one", "length"), + }, + }, + }) +} + +func Test_RemoveState_Error(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ExternalProviders: map[string]ExternalProvider{ + "random": { + Source: "registry.terraform.io/hashicorp/random", + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_string" "one" { + length = 2 + }`, + }, + { + RemoveState: []string{"resource.other"}, + ExpectError: regexp.MustCompile("Error: Invalid target address"), + }, + }, + }) +} diff --git a/helper/resource/teststep_validate.go b/helper/resource/teststep_validate.go index b1da44ad4..d71a71826 100644 --- a/helper/resource/teststep_validate.go +++ b/helper/resource/teststep_validate.go @@ -67,8 +67,8 @@ func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) err logging.HelperResourceTrace(ctx, "Validating TestStep") - if s.Config == "" && !s.ImportState && !s.RefreshState { - err := fmt.Errorf("TestStep missing Config or ImportState or RefreshState") + if s.Config == "" && !s.ImportState && !s.RefreshState && len(s.RemoveState) == 0 { + err := fmt.Errorf("TestStep missing Config or ImportState or RefreshState or RemoveState") logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) return err } @@ -97,6 +97,32 @@ func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) err return err } + if len(s.RemoveState) > 0 { + if req.StepNumber == 1 { + err := fmt.Errorf("TestStep cannot have RemoveState as first step") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.ImportState { + err := fmt.Errorf("TestStep cannot have RemoveState and ImportState in same step") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.RefreshState { + err := fmt.Errorf("TestStep cannot have RemoveState and RefreshState in same step") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.Config != "" { + err := fmt.Errorf("TestStep cannot have RemoveState and Config") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + } + for name := range s.ExternalProviders { if _, ok := s.ProviderFactories[name]; ok { err := fmt.Errorf("TestStep provider %q set in both ExternalProviders and ProviderFactories", name) diff --git a/helper/resource/teststep_validate_test.go b/helper/resource/teststep_validate_test.go index cf910c576..11fbc3fb6 100644 --- a/helper/resource/teststep_validate_test.go +++ b/helper/resource/teststep_validate_test.go @@ -88,7 +88,7 @@ func TestTestStepValidate(t *testing.T) { "config-and-importstate-and-refreshstate-missing": { testStep: TestStep{}, testStepValidateRequest: testStepValidateRequest{}, - expectedError: fmt.Errorf("TestStep missing Config or ImportState or RefreshState"), + expectedError: fmt.Errorf("TestStep missing Config or ImportState or RefreshState or RemoveState"), }, "config-and-refreshstate-both-set": { testStep: TestStep{ @@ -243,6 +243,36 @@ func TestTestStepValidate(t *testing.T) { testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, expectedError: errors.New("TestStep RefreshPlanChecks.PostRefresh must only be specified with RefreshState"), }, + "removestate-first-step": { + testStep: TestStep{ + RemoveState: []string{"resource.name"}, + }, + testStepValidateRequest: testStepValidateRequest{ + StepNumber: 1, + }, + expectedError: fmt.Errorf("TestStep cannot have RemoveState as first step"), + }, + "removestate-and-importstate": { + testStep: TestStep{ + ImportState: true, + RemoveState: []string{"resource.name"}, + }, + expectedError: fmt.Errorf("TestStep cannot have RemoveState and ImportState in same step"), + }, + "removestate-and-refreshstate": { + testStep: TestStep{ + RefreshState: true, + RemoveState: []string{"resource.name"}, + }, + expectedError: fmt.Errorf("TestStep cannot have RemoveState and RefreshState in same step"), + }, + "removestate-and-config": { + testStep: TestStep{ + Config: "# not empty", + RemoveState: []string{"resource.name"}, + }, + expectedError: fmt.Errorf("TestStep cannot have RemoveState and Config"), + }, } for name, test := range tests { diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index 9ee2046f2..3b0fea1a8 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -355,6 +355,17 @@ func (wd *WorkingDir) Taint(ctx context.Context, address string) error { return err } +// RemoveState runs terraform state rm +func (wd *WorkingDir) RemoveState(ctx context.Context, address string) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI state rm command") + + err := wd.tf.StateRm(context.Background(), address) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI state rm command") + + return err +} + // Refresh runs terraform refresh func (wd *WorkingDir) Refresh(ctx context.Context) error { logging.HelperResourceTrace(ctx, "Calling Terraform CLI refresh command") diff --git a/website/docs/plugin/testing/acceptance-tests/teststep.mdx b/website/docs/plugin/testing/acceptance-tests/teststep.mdx index 0c66a9ff7..196c1b5fd 100644 --- a/website/docs/plugin/testing/acceptance-tests/teststep.mdx +++ b/website/docs/plugin/testing/acceptance-tests/teststep.mdx @@ -15,7 +15,7 @@ under test. ## Test Modes Terraform's test framework facilitates three distinct modes of acceptance tests, -_Lifecycle (config)_, _Import_ and _Refresh_. +_Lifecycle (config)_, _Import_, _Refresh_ and _RemoveState_. _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 @@ -29,6 +29,16 @@ _Refresh_ mode is used for testing resource functionality to refresh existing infrastructure, using the same logic as would be used when running `terraform refresh`. +_RemoveState_ mode is used for testing resources that should not be deleted +at the end of the `TestCase`; providing a mechanism to remove said resources from state +before the final `destroy` command is executed. For example, this can be used with `Import`, +to test resources that are time-consuming or expensive to destroy and re-create for each test. + +Usage: +- Should not be present in the same `TestStep` as `Config: "not empty"`, `ImportState` or `RefreshState`. +- Should not be present in the first `TestStep` similar to `RefreshState`. + + 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).