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

Add RemoveState option to TestStep #118

Closed
wants to merge 12 commits into from
6 changes: 6 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20230714-065551.yaml
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions helper/resource/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
//---------------------------------------------------------------
Expand Down
43 changes: 43 additions & 0 deletions helper/resource/testing_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
29 changes: 29 additions & 0 deletions helper/resource/testing_new_remove_state.go
Original file line number Diff line number Diff line change
@@ -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
}
56 changes: 56 additions & 0 deletions helper/resource/testing_new_remove_state_test.go
Original file line number Diff line number Diff line change
@@ -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"),
},
},
})
}
30 changes: 28 additions & 2 deletions helper/resource/teststep_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 31 additions & 1 deletion helper/resource/teststep_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions internal/plugintest/working_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 12 additions & 1 deletion website/docs/plugin/testing/acceptance-tests/teststep.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +29,17 @@ _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`. It allows to clean up state from particular resources
that should not be automatically destroyed. For example, you want to write test
for existing resource: you import it, do some test steps and at the end of all steps
you need to keep, preventing automatic destroying.
vmanilo marked this conversation as resolved.
Show resolved Hide resolved

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`.
vmanilo marked this conversation as resolved.
Show resolved Hide resolved


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).
Expand Down