diff --git a/.changelog/1070.txt b/.changelog/1070.txt new file mode 100644 index 0000000000..53070197d3 --- /dev/null +++ b/.changelog/1070.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +helper/resource: Added `TestStep` type `RefreshState` field, which enables a step that refreshes state without an explicit apply or configuration changes +``` diff --git a/helper/resource/testing.go b/helper/resource/testing.go index e509586e35..c77f0629ac 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -570,6 +570,22 @@ type TestStep struct { // at the end of the test step that is verifying import behavior. ImportStatePersist bool + //--------------------------------------------------------------- + // RefreshState testing + //--------------------------------------------------------------- + + // RefreshState, if true, will test the functionality of `terraform + // refresh` by refreshing the state, running any checks against the + // refreshed state, and running a plan to verify against unexpected plan + // differences. + // + // If the refresh is expected to result in a non-empty plan + // ExpectNonEmptyPlan should be set to true in the same TestStep. + // + // RefreshState cannot be the first TestStep and, it is mutually exclusive + // with ImportState. + RefreshState bool + // ProviderFactories can be specified for the providers that are valid for // this TestStep. When providers are specified at the TestStep level, all // TestStep within a TestCase must declare providers. diff --git a/helper/resource/testing_new.go b/helper/resource/testing_new.go index 81fc59652b..6fa78c23ec 100644 --- a/helper/resource/testing_new.go +++ b/helper/resource/testing_new.go @@ -114,7 +114,7 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest logging.HelperResourceDebug(ctx, "Starting TestSteps") - // use this to track last step succesfully applied + // use this to track last step successfully applied // acts as default for import tests var appliedCfg string @@ -241,6 +241,45 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest continue } + if step.RefreshState { + logging.HelperResourceTrace(ctx, "TestStep is RefreshState mode") + + err := testStepNewRefreshState(ctx, t, wd, step, providers) + if step.ExpectError != nil { + logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError") + if err == nil { + logging.HelperResourceError(ctx, + "Error running refresh: expected an error but got none", + ) + t.Fatalf("Step %d/%d error running refresh: expected an error but got none", stepNumber, len(c.Steps)) + } + if !step.ExpectError.MatchString(err.Error()) { + logging.HelperResourceError(ctx, + fmt.Sprintf("Error running refresh: expected an error with pattern (%s)", step.ExpectError.String()), + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d error running refresh, expected an error with pattern (%s), no match on: %s", stepNumber, len(c.Steps), step.ExpectError.String(), 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, + "Error running refresh", + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d error running refresh: %s", stepNumber, len(c.Steps), err) + } + } + + logging.HelperResourceDebug(ctx, "Finished TestStep") + + continue + } + if step.Config != "" { logging.HelperResourceTrace(ctx, "TestStep is Config mode") diff --git a/helper/resource/testing_new_refresh_state.go b/helper/resource/testing_new_refresh_state.go new file mode 100644 index 0000000000..1611293474 --- /dev/null +++ b/helper/resource/testing_new_refresh_state.go @@ -0,0 +1,97 @@ +package resource + +import ( + "context" + "fmt" + + "github.com/davecgh/go-spew/spew" + tfjson "github.com/hashicorp/terraform-json" + "github.com/mitchellh/go-testing-interface" + + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugintest" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) error { + t.Helper() + + spewConf := spew.NewDefaultConfig() + spewConf.SortKeys = true + + var err error + // Explicitly ensure prior state exists before refresh. + err = runProviderCommand(ctx, t, func() error { + _, err = getState(ctx, t, wd) + if err != nil { + return err + } + return nil + }, wd, providers) + if err != nil { + t.Fatalf("Error getting state: %s", err) + } + + err = runProviderCommand(ctx, t, func() error { + return wd.Refresh(ctx) + }, wd, providers) + if err != nil { + return err + } + + var refreshState *terraform.State + err = runProviderCommand(ctx, t, func() error { + refreshState, err = getState(ctx, t, wd) + if err != nil { + return err + } + return nil + }, wd, providers) + if err != nil { + t.Fatalf("Error getting state: %s", err) + } + + // Go through the refreshed state and verify + if step.Check != nil { + logging.HelperResourceDebug(ctx, "Calling TestStep Check for RefreshState") + + if err := step.Check(refreshState); err != nil { + t.Fatal(err) + } + + logging.HelperResourceDebug(ctx, "Called TestStep Check for RefreshState") + } + + // do a plan + err = runProviderCommand(ctx, t, func() error { + return wd.CreatePlan(ctx) + }, wd, providers) + if err != nil { + return fmt.Errorf("Error running post-apply plan: %w", err) + } + + 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 post-apply plan: %w", err) + } + + if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan { + var stdout string + err = runProviderCommand(ctx, t, func() error { + var err error + stdout, err = wd.SavedPlanRawStdout(ctx) + return err + }, wd, providers) + if err != nil { + return fmt.Errorf("Error retrieving formatted plan output: %w", err) + } + return fmt.Errorf("After refreshing state during this test step, a followup plan was not empty.\nstdout:\n\n%s", stdout) + } + + return nil +} diff --git a/helper/resource/teststep_providers_test.go b/helper/resource/teststep_providers_test.go index bce1c19d1a..92ddbe0875 100644 --- a/helper/resource/teststep_providers_test.go +++ b/helper/resource/teststep_providers_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -1574,6 +1575,157 @@ func TestTest_TestStep_ProviderFactories_Import_External_WithoutPersistNonMatch( }) } +func TestTest_TestStep_ProviderFactories_Refresh_Inline(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + err := d.Set("min_special", 10) + if err != nil { + panic(err) + } + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + err := d.Set("min_special", 2) + if err != nil { + panic(err) + } + return nil + }, + Schema: map[string]*schema.Schema{ + "min_special": { + Computed: true, + Type: schema.TypeInt, + }, + + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + Check: TestCheckResourceAttr("random_password.test", "min_special", "10"), + }, + { + RefreshState: true, + Check: TestCheckResourceAttr("random_password.test", "min_special", "2"), + }, + { + Config: `resource "random_password" "test" { }`, + Check: TestCheckResourceAttr("random_password.test", "min_special", "2"), + }, + }, + }) +} + +func TestTest_TestStep_ProviderFactories_RefreshWithPlanModifier_Inline(t *testing.T) { + t.Parallel() + + Test(t, TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "random": func() (*schema.Provider, error) { //nolint:unparam // required signature + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "random_password": { + CustomizeDiff: customdiff.All( + func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) error { + special := d.Get("special").(bool) + if special == true { + err := d.SetNew("special", false) + if err != nil { + panic(err) + } + } + return nil + }, + ), + CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics { + d.SetId("id") + err := d.Set("special", false) + if err != nil { + panic(err) + } + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + t := getTimeForTest() + if t.After(time.Now().Add(time.Hour * 1)) { + err := d.Set("special", true) + if err != nil { + panic(err) + } + } + return nil + }, + Schema: map[string]*schema.Schema{ + "special": { + Computed: true, + Type: schema.TypeBool, + ForceNew: true, + }, + + "id": { + Computed: true, + Type: schema.TypeString, + }, + }, + }, + }, + }, nil + }, + }, + Steps: []TestStep{ + { + Config: `resource "random_password" "test" { }`, + Check: TestCheckResourceAttr("random_password.test", "special", "false"), + }, + { + PreConfig: setTimeForTest(time.Now().Add(time.Hour * 2)), + RefreshState: true, + ExpectNonEmptyPlan: true, + Check: TestCheckResourceAttr("random_password.test", "special", "true"), + }, + { + PreConfig: setTimeForTest(time.Now()), + Config: `resource "random_password" "test" { }`, + Check: TestCheckResourceAttr("random_password.test", "special", "false"), + }, + }, + }) +} + +func setTimeForTest(t time.Time) func() { + return func() { + getTimeForTest = func() time.Time { + return t + } + } +} + +var getTimeForTest = func() time.Time { + return time.Now() +} + func composeImportStateCheck(fs ...ImportStateCheckFunc) ImportStateCheckFunc { return func(s []*terraform.InstanceState) error { for i, f := range fs { diff --git a/helper/resource/teststep_validate.go b/helper/resource/teststep_validate.go index 95b36e7b97..0ba655168c 100644 --- a/helper/resource/teststep_validate.go +++ b/helper/resource/teststep_validate.go @@ -43,7 +43,10 @@ func (s TestStep) hasProviders(_ context.Context) bool { // validate ensures the TestStep is valid based on the following criteria: // -// - Config or ImportState is set. +// - Config or ImportState or RefreshState is set. +// - Config and RefreshState are not both set. +// - RefreshState and Destroy are not both set. +// - RefreshState is not the first TestStep. // - Providers are not specified (ExternalProviders, // ProtoV5ProviderFactories, ProtoV6ProviderFactories, ProviderFactories) // if specified at the TestCase level. @@ -58,8 +61,32 @@ func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) err logging.HelperResourceTrace(ctx, "Validating TestStep") - if s.Config == "" && !s.ImportState { - err := fmt.Errorf("TestStep missing Config or ImportState") + if s.Config == "" && !s.ImportState && !s.RefreshState { + err := fmt.Errorf("TestStep missing Config or ImportState or RefreshState") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.Config != "" && s.RefreshState { + err := fmt.Errorf("TestStep cannot have Config and RefreshState") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.RefreshState && s.Destroy { + err := fmt.Errorf("TestStep cannot have RefreshState and Destroy") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.RefreshState && req.StepNumber == 1 { + err := fmt.Errorf("TestStep cannot have RefreshState as first step") + logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) + return err + } + + if s.ImportState && s.RefreshState { + err := fmt.Errorf("TestStep cannot have ImportState and RefreshState in same step") logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err}) return err } diff --git a/helper/resource/teststep_validate_test.go b/helper/resource/teststep_validate_test.go index 8caf5c2332..15567a0788 100644 --- a/helper/resource/teststep_validate_test.go +++ b/helper/resource/teststep_validate_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -79,12 +80,42 @@ func TestTestStepValidate(t *testing.T) { testStepValidateRequest testStepValidateRequest expectedError error }{ - "config-and-importstate-missing": { - testStep: TestStep{}, + "config-and-importstate-and-refreshstate-missing": { + testStep: TestStep{}, + testStepValidateRequest: testStepValidateRequest{}, + expectedError: fmt.Errorf("TestStep missing Config or ImportState or RefreshState"), + }, + "config-and-refreshstate-both-set": { + testStep: TestStep{ + Config: "# not empty", + RefreshState: true, + }, + expectedError: fmt.Errorf("TestStep cannot have Config and RefreshState"), + }, + "refreshstate-first-step": { + testStep: TestStep{ + RefreshState: true, + }, testStepValidateRequest: testStepValidateRequest{ - TestCaseHasProviders: true, + StepNumber: 1, }, - expectedError: fmt.Errorf("TestStep missing Config or ImportState"), + expectedError: fmt.Errorf("TestStep cannot have RefreshState as first step"), + }, + "importstate-and-refreshstate-both-true": { + testStep: TestStep{ + ImportState: true, + RefreshState: true, + }, + testStepValidateRequest: testStepValidateRequest{}, + expectedError: fmt.Errorf("TestStep cannot have ImportState and RefreshState in same step"), + }, + "destroy-and-refreshstate-both-true": { + testStep: TestStep{ + Destroy: true, + RefreshState: true, + }, + testStepValidateRequest: testStepValidateRequest{}, + expectedError: fmt.Errorf("TestStep cannot have RefreshState and Destroy"), }, "externalproviders-overlapping-providerfactories": { testStep: TestStep{ diff --git a/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx b/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx index 5d0259e777..b0fe8b08a5 100644 --- a/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx +++ b/website/docs/plugin/sdkv2/testing/acceptance-tests/teststep.mdx @@ -14,8 +14,8 @@ under test. ## Test Modes -Terraform’s test framework facilitates two distinct modes of acceptance tests, -_Lifecycle_ and _Import_. +Terraform’s test framework facilitates three distinct modes of acceptance tests, +_Lifecycle_, _Import_ and _Refresh_. _Lifecycle_ 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 @@ -25,6 +25,10 @@ _Import_ mode is used for testing resource functionality to import existing infrastructure into a Terraform statefile, using the same logic as would be used when running `terraform import`. +_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 `TestStep` definition. The applicable fields are defined in the [TestStep Reference API](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-sdk/helper/resource#TestStep).