Skip to content

Commit 1dba057

Browse files
bendbennettbflad
andauthored
Adding RefreshState test step (#1070)
* Adding RefreshState test step (#1069) * Adding CHANGELOG entry (#1069) * Running TestCheckFunc with refreshed state (#1069) * Adding validation to check conditions for RefreshState (#1069) * Expanding comments on RefreshState (#1069) * Removing option to override config during refresh testing, removing re-init and adding plan following refresh (#1069) * Adding test coverage for to verify expect non-empty plan following refresh (#1069) * Apply suggestions from code review Co-authored-by: Brian Flad <[email protected]> * Adding validation to verify that refresh state is not present with config or destroy in a test step (#1069) * Reset time during test step so that ReadContext does not mutate state and result in a diff (#1069) * Updating website docs (#1069) * Apply suggestions from code review Co-authored-by: Brian Flad <[email protected]> * Test to verify that setting config and refresh state together is not valid (#1069) Co-authored-by: Brian Flad <[email protected]>
1 parent 3495894 commit 1dba057

File tree

8 files changed

+379
-10
lines changed

8 files changed

+379
-10
lines changed

Diff for: .changelog/1070.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:enhancement
2+
helper/resource: Added `TestStep` type `RefreshState` field, which enables a step that refreshes state without an explicit apply or configuration changes
3+
```

Diff for: helper/resource/testing.go

+16
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,22 @@ type TestStep struct {
570570
// at the end of the test step that is verifying import behavior.
571571
ImportStatePersist bool
572572

573+
//---------------------------------------------------------------
574+
// RefreshState testing
575+
//---------------------------------------------------------------
576+
577+
// RefreshState, if true, will test the functionality of `terraform
578+
// refresh` by refreshing the state, running any checks against the
579+
// refreshed state, and running a plan to verify against unexpected plan
580+
// differences.
581+
//
582+
// If the refresh is expected to result in a non-empty plan
583+
// ExpectNonEmptyPlan should be set to true in the same TestStep.
584+
//
585+
// RefreshState cannot be the first TestStep and, it is mutually exclusive
586+
// with ImportState.
587+
RefreshState bool
588+
573589
// ProviderFactories can be specified for the providers that are valid for
574590
// this TestStep. When providers are specified at the TestStep level, all
575591
// TestStep within a TestCase must declare providers.

Diff for: helper/resource/testing_new.go

+40-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest
114114

115115
logging.HelperResourceDebug(ctx, "Starting TestSteps")
116116

117-
// use this to track last step succesfully applied
117+
// use this to track last step successfully applied
118118
// acts as default for import tests
119119
var appliedCfg string
120120

@@ -241,6 +241,45 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest
241241
continue
242242
}
243243

244+
if step.RefreshState {
245+
logging.HelperResourceTrace(ctx, "TestStep is RefreshState mode")
246+
247+
err := testStepNewRefreshState(ctx, t, wd, step, providers)
248+
if step.ExpectError != nil {
249+
logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError")
250+
if err == nil {
251+
logging.HelperResourceError(ctx,
252+
"Error running refresh: expected an error but got none",
253+
)
254+
t.Fatalf("Step %d/%d error running refresh: expected an error but got none", stepNumber, len(c.Steps))
255+
}
256+
if !step.ExpectError.MatchString(err.Error()) {
257+
logging.HelperResourceError(ctx,
258+
fmt.Sprintf("Error running refresh: expected an error with pattern (%s)", step.ExpectError.String()),
259+
map[string]interface{}{logging.KeyError: err},
260+
)
261+
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)
262+
}
263+
} else {
264+
if err != nil && c.ErrorCheck != nil {
265+
logging.HelperResourceDebug(ctx, "Calling TestCase ErrorCheck")
266+
err = c.ErrorCheck(err)
267+
logging.HelperResourceDebug(ctx, "Called TestCase ErrorCheck")
268+
}
269+
if err != nil {
270+
logging.HelperResourceError(ctx,
271+
"Error running refresh",
272+
map[string]interface{}{logging.KeyError: err},
273+
)
274+
t.Fatalf("Step %d/%d error running refresh: %s", stepNumber, len(c.Steps), err)
275+
}
276+
}
277+
278+
logging.HelperResourceDebug(ctx, "Finished TestStep")
279+
280+
continue
281+
}
282+
244283
if step.Config != "" {
245284
logging.HelperResourceTrace(ctx, "TestStep is Config mode")
246285

Diff for: helper/resource/testing_new_refresh_state.go

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package resource
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/davecgh/go-spew/spew"
8+
tfjson "github.com/hashicorp/terraform-json"
9+
"github.com/mitchellh/go-testing-interface"
10+
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging"
12+
"github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugintest"
13+
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
14+
)
15+
16+
func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories) error {
17+
t.Helper()
18+
19+
spewConf := spew.NewDefaultConfig()
20+
spewConf.SortKeys = true
21+
22+
var err error
23+
// Explicitly ensure prior state exists before refresh.
24+
err = runProviderCommand(ctx, t, func() error {
25+
_, err = getState(ctx, t, wd)
26+
if err != nil {
27+
return err
28+
}
29+
return nil
30+
}, wd, providers)
31+
if err != nil {
32+
t.Fatalf("Error getting state: %s", err)
33+
}
34+
35+
err = runProviderCommand(ctx, t, func() error {
36+
return wd.Refresh(ctx)
37+
}, wd, providers)
38+
if err != nil {
39+
return err
40+
}
41+
42+
var refreshState *terraform.State
43+
err = runProviderCommand(ctx, t, func() error {
44+
refreshState, err = getState(ctx, t, wd)
45+
if err != nil {
46+
return err
47+
}
48+
return nil
49+
}, wd, providers)
50+
if err != nil {
51+
t.Fatalf("Error getting state: %s", err)
52+
}
53+
54+
// Go through the refreshed state and verify
55+
if step.Check != nil {
56+
logging.HelperResourceDebug(ctx, "Calling TestStep Check for RefreshState")
57+
58+
if err := step.Check(refreshState); err != nil {
59+
t.Fatal(err)
60+
}
61+
62+
logging.HelperResourceDebug(ctx, "Called TestStep Check for RefreshState")
63+
}
64+
65+
// do a plan
66+
err = runProviderCommand(ctx, t, func() error {
67+
return wd.CreatePlan(ctx)
68+
}, wd, providers)
69+
if err != nil {
70+
return fmt.Errorf("Error running post-apply plan: %w", err)
71+
}
72+
73+
var plan *tfjson.Plan
74+
err = runProviderCommand(ctx, t, func() error {
75+
var err error
76+
plan, err = wd.SavedPlan(ctx)
77+
return err
78+
}, wd, providers)
79+
if err != nil {
80+
return fmt.Errorf("Error retrieving post-apply plan: %w", err)
81+
}
82+
83+
if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
84+
var stdout string
85+
err = runProviderCommand(ctx, t, func() error {
86+
var err error
87+
stdout, err = wd.SavedPlanRawStdout(ctx)
88+
return err
89+
}, wd, providers)
90+
if err != nil {
91+
return fmt.Errorf("Error retrieving formatted plan output: %w", err)
92+
}
93+
return fmt.Errorf("After refreshing state during this test step, a followup plan was not empty.\nstdout:\n\n%s", stdout)
94+
}
95+
96+
return nil
97+
}

Diff for: helper/resource/teststep_providers_test.go

+152
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
1414

1515
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
16+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
1617
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1718
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
1819
)
@@ -1574,6 +1575,157 @@ func TestTest_TestStep_ProviderFactories_Import_External_WithoutPersistNonMatch(
15741575
})
15751576
}
15761577

1578+
func TestTest_TestStep_ProviderFactories_Refresh_Inline(t *testing.T) {
1579+
t.Parallel()
1580+
1581+
Test(t, TestCase{
1582+
ProviderFactories: map[string]func() (*schema.Provider, error){
1583+
"random": func() (*schema.Provider, error) { //nolint:unparam // required signature
1584+
return &schema.Provider{
1585+
ResourcesMap: map[string]*schema.Resource{
1586+
"random_password": {
1587+
CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics {
1588+
d.SetId("id")
1589+
err := d.Set("min_special", 10)
1590+
if err != nil {
1591+
panic(err)
1592+
}
1593+
return nil
1594+
},
1595+
DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics {
1596+
return nil
1597+
},
1598+
ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics {
1599+
err := d.Set("min_special", 2)
1600+
if err != nil {
1601+
panic(err)
1602+
}
1603+
return nil
1604+
},
1605+
Schema: map[string]*schema.Schema{
1606+
"min_special": {
1607+
Computed: true,
1608+
Type: schema.TypeInt,
1609+
},
1610+
1611+
"id": {
1612+
Computed: true,
1613+
Type: schema.TypeString,
1614+
},
1615+
},
1616+
},
1617+
},
1618+
}, nil
1619+
},
1620+
},
1621+
Steps: []TestStep{
1622+
{
1623+
Config: `resource "random_password" "test" { }`,
1624+
Check: TestCheckResourceAttr("random_password.test", "min_special", "10"),
1625+
},
1626+
{
1627+
RefreshState: true,
1628+
Check: TestCheckResourceAttr("random_password.test", "min_special", "2"),
1629+
},
1630+
{
1631+
Config: `resource "random_password" "test" { }`,
1632+
Check: TestCheckResourceAttr("random_password.test", "min_special", "2"),
1633+
},
1634+
},
1635+
})
1636+
}
1637+
1638+
func TestTest_TestStep_ProviderFactories_RefreshWithPlanModifier_Inline(t *testing.T) {
1639+
t.Parallel()
1640+
1641+
Test(t, TestCase{
1642+
ProviderFactories: map[string]func() (*schema.Provider, error){
1643+
"random": func() (*schema.Provider, error) { //nolint:unparam // required signature
1644+
return &schema.Provider{
1645+
ResourcesMap: map[string]*schema.Resource{
1646+
"random_password": {
1647+
CustomizeDiff: customdiff.All(
1648+
func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) error {
1649+
special := d.Get("special").(bool)
1650+
if special == true {
1651+
err := d.SetNew("special", false)
1652+
if err != nil {
1653+
panic(err)
1654+
}
1655+
}
1656+
return nil
1657+
},
1658+
),
1659+
CreateContext: func(ctx context.Context, d *schema.ResourceData, i interface{}) diag.Diagnostics {
1660+
d.SetId("id")
1661+
err := d.Set("special", false)
1662+
if err != nil {
1663+
panic(err)
1664+
}
1665+
return nil
1666+
},
1667+
DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics {
1668+
return nil
1669+
},
1670+
ReadContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics {
1671+
t := getTimeForTest()
1672+
if t.After(time.Now().Add(time.Hour * 1)) {
1673+
err := d.Set("special", true)
1674+
if err != nil {
1675+
panic(err)
1676+
}
1677+
}
1678+
return nil
1679+
},
1680+
Schema: map[string]*schema.Schema{
1681+
"special": {
1682+
Computed: true,
1683+
Type: schema.TypeBool,
1684+
ForceNew: true,
1685+
},
1686+
1687+
"id": {
1688+
Computed: true,
1689+
Type: schema.TypeString,
1690+
},
1691+
},
1692+
},
1693+
},
1694+
}, nil
1695+
},
1696+
},
1697+
Steps: []TestStep{
1698+
{
1699+
Config: `resource "random_password" "test" { }`,
1700+
Check: TestCheckResourceAttr("random_password.test", "special", "false"),
1701+
},
1702+
{
1703+
PreConfig: setTimeForTest(time.Now().Add(time.Hour * 2)),
1704+
RefreshState: true,
1705+
ExpectNonEmptyPlan: true,
1706+
Check: TestCheckResourceAttr("random_password.test", "special", "true"),
1707+
},
1708+
{
1709+
PreConfig: setTimeForTest(time.Now()),
1710+
Config: `resource "random_password" "test" { }`,
1711+
Check: TestCheckResourceAttr("random_password.test", "special", "false"),
1712+
},
1713+
},
1714+
})
1715+
}
1716+
1717+
func setTimeForTest(t time.Time) func() {
1718+
return func() {
1719+
getTimeForTest = func() time.Time {
1720+
return t
1721+
}
1722+
}
1723+
}
1724+
1725+
var getTimeForTest = func() time.Time {
1726+
return time.Now()
1727+
}
1728+
15771729
func composeImportStateCheck(fs ...ImportStateCheckFunc) ImportStateCheckFunc {
15781730
return func(s []*terraform.InstanceState) error {
15791731
for i, f := range fs {

Diff for: helper/resource/teststep_validate.go

+30-3
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ func (s TestStep) hasProviders(_ context.Context) bool {
4343

4444
// validate ensures the TestStep is valid based on the following criteria:
4545
//
46-
// - Config or ImportState is set.
46+
// - Config or ImportState or RefreshState is set.
47+
// - Config and RefreshState are not both set.
48+
// - RefreshState and Destroy are not both set.
49+
// - RefreshState is not the first TestStep.
4750
// - Providers are not specified (ExternalProviders,
4851
// ProtoV5ProviderFactories, ProtoV6ProviderFactories, ProviderFactories)
4952
// if specified at the TestCase level.
@@ -58,8 +61,32 @@ func (s TestStep) validate(ctx context.Context, req testStepValidateRequest) err
5861

5962
logging.HelperResourceTrace(ctx, "Validating TestStep")
6063

61-
if s.Config == "" && !s.ImportState {
62-
err := fmt.Errorf("TestStep missing Config or ImportState")
64+
if s.Config == "" && !s.ImportState && !s.RefreshState {
65+
err := fmt.Errorf("TestStep missing Config or ImportState or RefreshState")
66+
logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err})
67+
return err
68+
}
69+
70+
if s.Config != "" && s.RefreshState {
71+
err := fmt.Errorf("TestStep cannot have Config and RefreshState")
72+
logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err})
73+
return err
74+
}
75+
76+
if s.RefreshState && s.Destroy {
77+
err := fmt.Errorf("TestStep cannot have RefreshState and Destroy")
78+
logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err})
79+
return err
80+
}
81+
82+
if s.RefreshState && req.StepNumber == 1 {
83+
err := fmt.Errorf("TestStep cannot have RefreshState as first step")
84+
logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err})
85+
return err
86+
}
87+
88+
if s.ImportState && s.RefreshState {
89+
err := fmt.Errorf("TestStep cannot have ImportState and RefreshState in same step")
6390
logging.HelperResourceError(ctx, "TestStep validation error", map[string]interface{}{logging.KeyError: err})
6491
return err
6592
}

0 commit comments

Comments
 (0)