Skip to content

Commit

Permalink
Adding StateCheck interface (#266)
Browse files Browse the repository at this point in the history
  * Configuring when state checks are executed.
  * Testing that state checks are executed.
  • Loading branch information
bendbennett committed Jan 15, 2024
1 parent 198c751 commit f67b3e7
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 0 deletions.
29 changes: 29 additions & 0 deletions helper/resource/state_checks.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"
"errors"

tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/go-testing-interface"

"github.com/hashicorp/terraform-plugin-testing/statecheck"
)

func runStateChecks(ctx context.Context, t testing.T, state *tfjson.State, stateChecks []statecheck.StateCheck) error {
t.Helper()

var result []error

for _, stateCheck := range stateChecks {
resp := statecheck.CheckStateResponse{}
stateCheck.CheckState(ctx, statecheck.CheckStateRequest{State: state}, &resp)

result = append(result, resp.Error)
}

return errors.Join(result...)
}
22 changes: 22 additions & 0 deletions helper/resource/state_checks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package resource

import (
"context"

"github.com/hashicorp/terraform-plugin-testing/statecheck"
)

var _ statecheck.StateCheck = &stateCheckSpy{}

type stateCheckSpy struct {
err error
called bool
}

func (s *stateCheckSpy) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
s.called = true
resp.Error = s.err
}
12 changes: 12 additions & 0 deletions helper/resource/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (

"github.com/hashicorp/terraform-plugin-testing/config"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/hashicorp/terraform-plugin-testing/tfversion"

Expand Down Expand Up @@ -590,6 +591,13 @@ type TestStep struct {
// [plancheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck
RefreshPlanChecks RefreshPlanChecks

// ConfigStateChecks allow assertions to be made against the state file at different points of a Config (apply) test using a state check.
// Custom state checks can be created by implementing the [StateCheck] interface, or by using a StateCheck implementation from the provided [statecheck] package
//
// [StateCheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/statecheck#StateCheck
// [statecheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/statecheck
ConfigStateChecks ConfigStateChecks

// PlanOnly can be set to only run `plan` with this configuration, and not
// actually apply it. This is useful for ensuring config changes result in
// no-op plans
Expand Down Expand Up @@ -795,6 +803,10 @@ type RefreshPlanChecks struct {
PostRefresh []plancheck.PlanCheck
}

// ConfigStateChecks runs all state checks in the slice. This occurs after the apply and refresh of a Config test are run.
// All errors by state checks in this slice are aggregated, reported, and will result in a test failure.
type ConfigStateChecks []statecheck.StateCheck

// ParallelTest performs an acceptance test on a resource, allowing concurrency
// with other ParallelTest. The number of concurrent tests is controlled by the
// "go test" command -parallel flag.
Expand Down
20 changes: 20 additions & 0 deletions helper/resource/testing_new_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,26 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint
}
}

// Run post-apply, post-refresh state checks
if len(step.ConfigStateChecks) > 0 {
var state *tfjson.State

err = runProviderCommand(ctx, t, func() error {
var err error
state, err = wd.State(ctx)
return err
}, wd, providers)

if err != nil {
return fmt.Errorf("Error retrieving post-apply, post-refresh state: %w", err)
}

err = runStateChecks(ctx, t, state, step.ConfigStateChecks)
if err != nil {
return fmt.Errorf("Post-apply refresh state check(s) failed:\n%w", err)
}
}

// check if plan is empty
if !planIsEmpty(plan, helper.TerraformVersion()) && !step.ExpectNonEmptyPlan {
var stdout string
Expand Down
124 changes: 124 additions & 0 deletions helper/resource/testing_new_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-go/tftypes"

"github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider"
"github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver"
"github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource"
Expand Down Expand Up @@ -717,3 +718,126 @@ func Test_ConfigPlanChecks_PostApplyPostRefresh_Errors(t *testing.T) {
},
})
}

func Test_ConfigStateChecks_Called(t *testing.T) {
t.Parallel()

spy1 := &stateCheckSpy{}
spy2 := &stateCheckSpy{}
UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"test": providerserver.NewProviderServer(testprovider.Provider{
Resources: map[string]testprovider.Resource{
"test_resource": {
CreateResponse: &resource.CreateResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "test"),
},
),
},
SchemaResponse: &resource.SchemaResponse{
Schema: &tfprotov6.Schema{
Block: &tfprotov6.SchemaBlock{
Attributes: []*tfprotov6.SchemaAttribute{
{
Name: "id",
Type: tftypes.String,
Computed: true,
},
},
},
},
},
},
},
}),
},
Steps: []TestStep{
{
Config: `resource "test_resource" "test" {}`,
ConfigStateChecks: ConfigStateChecks{
spy1,
spy2,
},
},
},
})

if !spy1.called {
t.Error("expected ConfigStateChecks spy1 to be called at least once")
}

if !spy2.called {
t.Error("expected ConfigStateChecks spy2 to be called at least once")
}
}

func Test_ConfigStateChecks_Errors(t *testing.T) {
t.Parallel()

spy1 := &stateCheckSpy{}
spy2 := &stateCheckSpy{
err: errors.New("spy2 check failed"),
}
spy3 := &stateCheckSpy{
err: errors.New("spy3 check failed"),
}
UnitTest(t, TestCase{
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories
},
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
"test": providerserver.NewProviderServer(testprovider.Provider{
Resources: map[string]testprovider.Resource{
"test_resource": {
CreateResponse: &resource.CreateResponse{
NewState: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "test"),
},
),
},
SchemaResponse: &resource.SchemaResponse{
Schema: &tfprotov6.Schema{
Block: &tfprotov6.SchemaBlock{
Attributes: []*tfprotov6.SchemaAttribute{
{
Name: "id",
Type: tftypes.String,
Computed: true,
},
},
},
},
},
},
},
}),
},
Steps: []TestStep{
{
Config: `resource "test_resource" "test" {}`,
ConfigStateChecks: ConfigStateChecks{
spy1,
spy2,
spy3,
},
ExpectError: regexp.MustCompile(`.*?(spy2 check failed)\n.*?(spy3 check failed)`),
},
},
})
}
5 changes: 5 additions & 0 deletions statecheck/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

// Package statecheck contains the state check interface, request/response structs, and common state check implementations.
package statecheck
30 changes: 30 additions & 0 deletions statecheck/state_check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package statecheck

import (
"context"

tfjson "github.com/hashicorp/terraform-json"
)

// StateCheck defines an interface for implementing test logic that checks a state file and then returns an error
// if the state file does not match what is expected.
type StateCheck interface {
// CheckState should perform the state check.
CheckState(context.Context, CheckStateRequest, *CheckStateResponse)
}

// CheckStateRequest is a request for an invoke of the CheckState function.
type CheckStateRequest struct {
// State represents a parsed state file, retrieved via the `terraform show -json` command.
State *tfjson.State
}

// CheckStateResponse is a response to an invoke of the CheckState function.
type CheckStateResponse struct {
// Error is used to report the failure of a state check assertion and is combined with other StateCheck errors
// to be reported as a test failure.
Error error
}

0 comments on commit f67b3e7

Please sign in to comment.