Skip to content

Commit 38ccdbd

Browse files
authored
helper/resource: Ensure TestStep.ExpectNonEmptyPlan accounts for output changes (#234)
Reference: #222 This is a followup to similar `plancheck` implementation updates that ensure output changes are checked in the plan in addition to resource changes. The intention of this functionality is to catch any plan differences and has been documented in this manner for a long while. Since `TestStep.ExpectNonEmptyPlan` pre-dates this Go module, for extra compatibility for developers migrating, output checking is only enabled for Terraform 0.14 and later since prior versions will always show changes when outputs are present in the configuration. Ideally `TestStep.ExpectNonEmptyPlan` will be deprecated in preference of the newer plan checks since its abstraction is fairly ambiguous to when its being invoked, but this deprecation may come by nature of larger changes for a next major version.
1 parent 75af38e commit 38ccdbd

File tree

7 files changed

+368
-5
lines changed

7 files changed

+368
-5
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: BUG FIXES
2+
body: 'helper/resource: Ensured `TestStep.ExpectNonEmptyPlan` accounts for output
3+
changes with Terraform 0.14 and later'
4+
time: 2023-11-30T10:23:26.348382-05:00
5+
custom:
6+
Issue: "234"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
kind: NOTES
2+
body: 'helper/resource: Configuration based `TestStep` now include post-apply plan
3+
checks for output changes in addition to resource changes. If this causes
4+
unexpected new test failures, most `output` configuration blocks can be likely
5+
be removed. Test steps involving resources and data sources should never need
6+
to use `output` configuration blocks as plan and state checks support working
7+
on resource and data source attributes values directly.'
8+
time: 2023-12-01T16:02:57.111641-05:00
9+
custom:
10+
Issue: "234"

helper/resource/testing_new.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/google/go-cmp/cmp"
16+
"github.com/hashicorp/go-version"
1617
tfjson "github.com/hashicorp/terraform-json"
1718
"github.com/mitchellh/go-testing-interface"
1819

@@ -476,14 +477,25 @@ func stateIsEmpty(state *terraform.State) bool {
476477
return state.Empty() || !state.HasResources() //nolint:staticcheck // legacy usage
477478
}
478479

479-
func planIsEmpty(plan *tfjson.Plan) bool {
480+
func planIsEmpty(plan *tfjson.Plan, tfVersion *version.Version) bool {
480481
for _, rc := range plan.ResourceChanges {
481482
for _, a := range rc.Change.Actions {
482483
if a != tfjson.ActionNoop {
483484
return false
484485
}
485486
}
486487
}
488+
489+
if tfVersion.LessThan(expectNonEmptyPlanOutputChangesMinTFVersion) {
490+
return true
491+
}
492+
493+
for _, change := range plan.OutputChanges {
494+
if !change.Actions.NoOp() {
495+
return false
496+
}
497+
}
498+
487499
return true
488500
}
489501

helper/resource/testing_new_config.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ import (
1515
"github.com/hashicorp/terraform-plugin-testing/config"
1616
"github.com/hashicorp/terraform-plugin-testing/internal/teststep"
1717
"github.com/hashicorp/terraform-plugin-testing/terraform"
18+
"github.com/hashicorp/terraform-plugin-testing/tfversion"
1819

1920
"github.com/hashicorp/terraform-plugin-testing/internal/logging"
2021
"github.com/hashicorp/terraform-plugin-testing/internal/plugintest"
2122
)
2223

24+
// expectNonEmptyPlanOutputChangesMinTFVersion is used to keep compatibility for
25+
// Terraform 0.12 and 0.13 after enabling ExpectNonEmptyPlan to check output
26+
// changes. Those older versions will always show outputs being created.
27+
var expectNonEmptyPlanOutputChangesMinTFVersion = tfversion.Version0_14_0
28+
2329
func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories, stepIndex int, helper *plugintest.Helper) error {
2430
t.Helper()
2531

@@ -236,7 +242,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
236242
}
237243
}
238244

239-
if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
245+
if !planIsEmpty(plan, helper.TerraformVersion()) && !step.ExpectNonEmptyPlan {
240246
var stdout string
241247
err = runProviderCommand(ctx, t, func() error {
242248
var err error
@@ -283,7 +289,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
283289
}
284290

285291
// check if plan is empty
286-
if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
292+
if !planIsEmpty(plan, helper.TerraformVersion()) && !step.ExpectNonEmptyPlan {
287293
var stdout string
288294
err = runProviderCommand(ctx, t, func() error {
289295
var err error
@@ -294,7 +300,7 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
294300
return fmt.Errorf("Error retrieving formatted second plan output: %w", err)
295301
}
296302
return fmt.Errorf("After applying this test step and performing a `terraform refresh`, the plan was not empty.\nstdout\n\n%s", stdout)
297-
} else if step.ExpectNonEmptyPlan && planIsEmpty(plan) {
303+
} else if step.ExpectNonEmptyPlan && planIsEmpty(plan, helper.TerraformVersion()) {
298304
return errors.New("Expected a non-empty plan, but got an empty plan")
299305
}
300306

helper/resource/testing_new_config_test.go

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,278 @@ func TestTest_TestStep_ExpectError_NewConfig(t *testing.T) {
6565
})
6666
}
6767

68+
func Test_ExpectNonEmptyPlan_EmptyPlanError(t *testing.T) {
69+
t.Parallel()
70+
71+
UnitTest(t, TestCase{
72+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
73+
tfversion.SkipBelow(tfversion.Version1_4_0),
74+
},
75+
ExternalProviders: map[string]ExternalProvider{
76+
"terraform": {Source: "terraform.io/builtin/terraform"},
77+
},
78+
Steps: []TestStep{
79+
{
80+
Config: `resource "terraform_data" "test" {}`,
81+
ExpectNonEmptyPlan: true,
82+
ExpectError: regexp.MustCompile("Expected a non-empty plan, but got an empty plan"),
83+
},
84+
},
85+
})
86+
}
87+
88+
func Test_ExpectNonEmptyPlan_PreRefresh_ResourceChanges(t *testing.T) {
89+
t.Parallel()
90+
91+
UnitTest(t, TestCase{
92+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
93+
tfversion.SkipBelow(tfversion.Version1_4_0),
94+
},
95+
ExternalProviders: map[string]ExternalProvider{
96+
"terraform": {Source: "terraform.io/builtin/terraform"},
97+
},
98+
Steps: []TestStep{
99+
{
100+
Config: `resource "terraform_data" "test" {
101+
# Never recommended for real world configurations, but tests
102+
# the intended behavior.
103+
input = timestamp()
104+
}`,
105+
ConfigPlanChecks: ConfigPlanChecks{
106+
// Verification of that the behavior is being caught pre
107+
// refresh. We want to ensure ExpectNonEmptyPlan allows test
108+
// to pass if pre refresh also has changes.
109+
PostApplyPreRefresh: []plancheck.PlanCheck{
110+
plancheck.ExpectResourceAction("terraform_data.test", plancheck.ResourceActionUpdate),
111+
},
112+
},
113+
ExpectNonEmptyPlan: true,
114+
},
115+
},
116+
})
117+
}
118+
119+
func Test_ExpectNonEmptyPlan_PostRefresh_OutputChanges(t *testing.T) {
120+
t.Parallel()
121+
122+
UnitTest(t, TestCase{
123+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
124+
tfversion.SkipAbove(tfversion.Version0_14_0), // outputs before 0.14 always show as created
125+
},
126+
// Avoid our own validation that requires at least one provider config.
127+
ExternalProviders: map[string]ExternalProvider{
128+
"terraform": {Source: "terraform.io/builtin/terraform"},
129+
},
130+
Steps: []TestStep{
131+
{
132+
Config: `output "test" { value = timestamp() }`,
133+
ExpectNonEmptyPlan: false, // compatibility compromise for 0.12 and 0.13
134+
},
135+
},
136+
})
137+
138+
UnitTest(t, TestCase{
139+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
140+
tfversion.SkipBelow(tfversion.Version0_14_0), // outputs before 0.14 always show as created
141+
},
142+
// Avoid our own validation that requires at least one provider config.
143+
ExternalProviders: map[string]ExternalProvider{
144+
"terraform": {Source: "terraform.io/builtin/terraform"},
145+
},
146+
Steps: []TestStep{
147+
{
148+
Config: `output "test" { value = timestamp() }`,
149+
ExpectNonEmptyPlan: true,
150+
},
151+
},
152+
})
153+
}
154+
155+
func Test_ExpectNonEmptyPlan_PostRefresh_ResourceChanges(t *testing.T) {
156+
t.Parallel()
157+
158+
UnitTest(t, TestCase{
159+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
160+
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
161+
},
162+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
163+
"test": providerserver.NewProviderServer(testprovider.Provider{
164+
Resources: map[string]testprovider.Resource{
165+
"test_resource": {
166+
CreateResponse: &resource.CreateResponse{
167+
NewState: tftypes.NewValue(
168+
tftypes.Object{
169+
AttributeTypes: map[string]tftypes.Type{
170+
"id": tftypes.String,
171+
},
172+
},
173+
map[string]tftypes.Value{
174+
"id": tftypes.NewValue(tftypes.String, "test"), // intentionally same
175+
},
176+
),
177+
},
178+
ReadResponse: &resource.ReadResponse{
179+
NewState: tftypes.NewValue(
180+
tftypes.Object{
181+
AttributeTypes: map[string]tftypes.Type{
182+
"id": tftypes.String,
183+
},
184+
},
185+
map[string]tftypes.Value{
186+
"id": tftypes.NewValue(tftypes.String, "not-test"), // intentionally different
187+
},
188+
),
189+
},
190+
SchemaResponse: &resource.SchemaResponse{
191+
Schema: &tfprotov6.Schema{
192+
Block: &tfprotov6.SchemaBlock{
193+
Attributes: []*tfprotov6.SchemaAttribute{
194+
{
195+
Name: "id",
196+
Type: tftypes.String,
197+
Required: true,
198+
},
199+
},
200+
},
201+
},
202+
},
203+
},
204+
},
205+
}),
206+
},
207+
Steps: []TestStep{
208+
{
209+
Config: `resource "test_resource" "test" {
210+
# Post create refresh intentionally changes configured value
211+
# which is an errant resource implementation. Create should
212+
# account for the correct post creation state, preventing an
213+
# immediate difference next Terraform run for practitioners.
214+
# This errant resource behavior verifies the expected
215+
# behavior of ExpectNonEmptyPlan for post refresh planning.
216+
id = "test"
217+
}`,
218+
ConfigPlanChecks: ConfigPlanChecks{
219+
// Verification of that the behavior is being caught post
220+
// refresh. We want to ensure ExpectNonEmptyPlan is being
221+
// triggered after the pre refresh plan shows no changes.
222+
PostApplyPreRefresh: []plancheck.PlanCheck{
223+
plancheck.ExpectResourceAction("test_resource.test", plancheck.ResourceActionNoop),
224+
},
225+
},
226+
ExpectNonEmptyPlan: true,
227+
},
228+
},
229+
})
230+
}
231+
232+
func Test_NonEmptyPlan_PreRefresh_Error(t *testing.T) {
233+
t.Parallel()
234+
235+
UnitTest(t, TestCase{
236+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
237+
tfversion.SkipBelow(tfversion.Version1_4_0),
238+
},
239+
ExternalProviders: map[string]ExternalProvider{
240+
"terraform": {Source: "terraform.io/builtin/terraform"},
241+
},
242+
Steps: []TestStep{
243+
{
244+
Config: `resource "terraform_data" "test" {
245+
# Never recommended for real world configurations, but tests
246+
# the intended behavior.
247+
input = timestamp()
248+
}`,
249+
ConfigPlanChecks: ConfigPlanChecks{
250+
// Verification of that the behavior is being caught pre
251+
// refresh.
252+
PostApplyPreRefresh: []plancheck.PlanCheck{
253+
plancheck.ExpectResourceAction("terraform_data.test", plancheck.ResourceActionUpdate),
254+
},
255+
},
256+
ExpectNonEmptyPlan: false, // intentional
257+
ExpectError: regexp.MustCompile("After applying this test step, the plan was not empty."),
258+
},
259+
},
260+
})
261+
}
262+
263+
func Test_NonEmptyPlan_PostRefresh_Error(t *testing.T) {
264+
t.Parallel()
265+
266+
UnitTest(t, TestCase{
267+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
268+
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
269+
},
270+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
271+
"test": providerserver.NewProviderServer(testprovider.Provider{
272+
Resources: map[string]testprovider.Resource{
273+
"test_resource": {
274+
CreateResponse: &resource.CreateResponse{
275+
NewState: tftypes.NewValue(
276+
tftypes.Object{
277+
AttributeTypes: map[string]tftypes.Type{
278+
"id": tftypes.String,
279+
},
280+
},
281+
map[string]tftypes.Value{
282+
"id": tftypes.NewValue(tftypes.String, "test"), // intentionally same
283+
},
284+
),
285+
},
286+
ReadResponse: &resource.ReadResponse{
287+
NewState: tftypes.NewValue(
288+
tftypes.Object{
289+
AttributeTypes: map[string]tftypes.Type{
290+
"id": tftypes.String,
291+
},
292+
},
293+
map[string]tftypes.Value{
294+
"id": tftypes.NewValue(tftypes.String, "not-test"), // intentionally different
295+
},
296+
),
297+
},
298+
SchemaResponse: &resource.SchemaResponse{
299+
Schema: &tfprotov6.Schema{
300+
Block: &tfprotov6.SchemaBlock{
301+
Attributes: []*tfprotov6.SchemaAttribute{
302+
{
303+
Name: "id",
304+
Type: tftypes.String,
305+
Required: true,
306+
},
307+
},
308+
},
309+
},
310+
},
311+
},
312+
},
313+
}),
314+
},
315+
Steps: []TestStep{
316+
{
317+
Config: `resource "test_resource" "test" {
318+
# Post create refresh intentionally changes configured value
319+
# which is an errant resource implementation. Create should
320+
# account for the correct post creation state, preventing an
321+
# immediate difference next Terraform run for practitioners.
322+
# This errant resource behavior verifies the expected
323+
# behavior of ExpectNonEmptyPlan for post refresh planning.
324+
id = "test"
325+
}`,
326+
ConfigPlanChecks: ConfigPlanChecks{
327+
// Verification of that the behavior is being caught post
328+
// refresh.
329+
PostApplyPreRefresh: []plancheck.PlanCheck{
330+
plancheck.ExpectResourceAction("test_resource.test", plancheck.ResourceActionNoop),
331+
},
332+
},
333+
ExpectNonEmptyPlan: false, // intentional
334+
ExpectError: regexp.MustCompile("After applying this test step and performing a `terraform refresh`, the plan was not empty."),
335+
},
336+
},
337+
})
338+
}
339+
68340
func Test_ConfigPlanChecks_PreApply_Called(t *testing.T) {
69341
t.Parallel()
70342

helper/resource/testing_new_refresh_state.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func testStepNewRefreshState(ctx context.Context, t testing.T, wd *plugintest.Wo
8888
}
8989
}
9090

91-
if !planIsEmpty(plan) && !step.ExpectNonEmptyPlan {
91+
if !planIsEmpty(plan, wd.GetHelper().TerraformVersion()) && !step.ExpectNonEmptyPlan {
9292
var stdout string
9393
err = runProviderCommand(ctx, t, func() error {
9494
var err error

0 commit comments

Comments
 (0)