[Testing Framework] Adds TestContext for evaluating test assertions (#33326)

* Add test structure to views package for rendering test output

* Add test file HCL configuration and parser functionality

* Adds a TestContext structure for evaluating assertions against the state and plan
This commit is contained in:
Liam Cervante 2023-06-26 17:42:53 +02:00 committed by GitHub
parent d49e991c3c
commit 3732bffe13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 909 additions and 34 deletions

View File

@ -7,11 +7,12 @@ import (
"sort"
"time"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/lang/globalref"
"github.com/hashicorp/terraform/internal/states"
"github.com/zclconf/go-cty/cty"
)
// Plan is the top-level type representing a planned set of changes.
@ -88,6 +89,29 @@ type Plan struct {
PrevRunState *states.State
PriorState *states.State
// PlannedState is the temporary planned state that was created during the
// graph walk that generated this plan.
//
// This is required by the testing framework when evaluating run blocks
// executing in plan mode. The graph updates the state with certain values
// that are difficult to retrieve later, such as local values that reference
// updated resources. It is easier to build the testing scope with access
// to same temporary state the plan used/built.
//
// This is never recorded outside of Terraform. It is not written into the
// binary plan file, and it is not written into the JSON structured outputs.
// The testing framework never writes the plans out but holds everything in
// memory as it executes, so there is no need to add any kind of
// serialization for this field. This does mean that you shouldn't rely on
// this field existing unless you have just generated the plan.
PlannedState *states.State
// ExternalReferences are references that are being made to resources within
// the plan from external sources. As with PlannedState this is used by the
// terraform testing framework, and so isn't written into any external
// representation of the plan.
ExternalReferences []*addrs.Reference
// Timestamp is the record of truth for when the plan happened.
Timestamp time.Time
}

View File

@ -7,12 +7,13 @@ import (
"fmt"
"log"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
// Apply performs the actions described by the given Plan object and returns
@ -176,6 +177,7 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, validate
Targets: plan.TargetAddrs,
ForceReplace: plan.ForceReplaceAddrs,
Operation: operation,
ExternalReferences: plan.ExternalReferences,
}).Build(addrs.RootModuleInstance)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {

View File

@ -2162,3 +2162,85 @@ import {
t.Errorf("expected addr to be %s, but was %s", wantAddr, addr)
}
}
func TestContext2Apply_noExternalReferences(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
plan, diags := ctx.Plan(m, states.NewState(), nil)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}
state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}
// We didn't specify any external references, so the unreferenced local
// value should have been tidied up and never made it into the state.
module := state.RootModule()
if len(module.LocalValues) > 0 {
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
}
}
func TestContext2Apply_withExternalReferences(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
Mode: plans.NormalMode,
ExternalReferences: []*addrs.Reference{
mustReference("local.local_value"),
},
})
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}
state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}
// We did specify the local value in the external references, so it should
// have been preserved even though it is not referenced by anything directly
// in the config.
module := state.RootModule()
if module.LocalValues["local_value"].AsString() != "foo" {
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
}
}

View File

@ -74,6 +74,11 @@ type PlanOpts struct {
// fully-functional new object.
ForceReplace []addrs.AbsResourceInstance
// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference
// ImportTargets is a list of target resources to import. These resources
// will be added to the plan graph.
ImportTargets []*ImportTarget
@ -644,13 +649,15 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o
diags = diags.Append(driftDiags)
plan := &plans.Plan{
UIMode: opts.Mode,
Changes: changes,
DriftedResources: driftedResources,
PrevRunState: prevRunState,
PriorState: priorState,
Checks: states.NewCheckResults(walker.Checks),
Timestamp: timestamp,
UIMode: opts.Mode,
Changes: changes,
DriftedResources: driftedResources,
PrevRunState: prevRunState,
PriorState: priorState,
PlannedState: walker.State.Close(),
ExternalReferences: opts.ExternalReferences,
Checks: states.NewCheckResults(walker.Checks),
Timestamp: timestamp,
// Other fields get populated by Context.Plan after we return
}
@ -670,6 +677,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
skipRefresh: opts.SkipRefresh,
preDestroyRefresh: opts.PreDestroyRefresh,
Operation: walkPlan,
ExternalReferences: opts.ExternalReferences,
ImportTargets: opts.ImportTargets,
GenerateConfigPath: opts.GenerateConfigPath,
}).Build(addrs.RootModuleInstance)
@ -684,6 +692,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
skipRefresh: opts.SkipRefresh,
skipPlanChanges: true, // this activates "refresh only" mode.
Operation: walkPlan,
ExternalReferences: opts.ExternalReferences,
}).Build(addrs.RootModuleInstance)
return graph, walkPlan, diags
case plans.DestroyMode:

View File

@ -13,6 +13,8 @@ import (
"github.com/davecgh/go-spew/spew"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/configs/configschema"
@ -21,7 +23,6 @@ import (
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
func TestContext2Plan_removedDuringRefresh(t *testing.T) {
@ -4888,3 +4889,54 @@ import {
t.Errorf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff)
}
}
func TestContext2Plan_plannedState(t *testing.T) {
addr := mustResourceInstanceAddr("test_object.a")
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "foo"
}
locals {
local_value = test_object.a.test_string
}
`,
})
p := simpleMockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
state := states.NewState()
plan, diags := ctx.Plan(m, state, nil)
if diags.HasErrors() {
t.Errorf("expected no errors, but got %s", diags)
}
module := state.RootModule()
// So, the original state shouldn't have been updated at all.
if len(module.LocalValues) > 0 {
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
}
if len(module.Resources) > 0 {
t.Errorf("expected no resources in the state but found %d", len(module.LocalValues))
}
// But, this makes it hard for the testing framework to valid things about
// the returned plan. So, the plan contains the planned state:
module = plan.PlannedState.RootModule()
if module.LocalValues["local_value"].AsString() != "foo" {
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
}
if module.ResourceInstance(addr.Resource).Current.Status != states.ObjectPlanned {
t.Errorf("expected resource to be in planned state")
}
}

View File

@ -11,13 +11,13 @@ import (
"sync"
"time"
"github.com/agext/levenshtein"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/didyoumean"
"github.com/hashicorp/terraform/internal/instances"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/lang/marks"
@ -229,7 +229,7 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd
for k := range moduleConfig.Module.Variables {
suggestions = append(suggestions, k)
}
suggestion := nameSuggestion(addr.Name, suggestions)
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
} else {
@ -325,7 +325,7 @@ func (d *evaluationStateData) GetLocalValue(addr addrs.LocalValue, rng tfdiags.S
for k := range moduleConfig.Module.Locals {
suggestions = append(suggestions, k)
}
suggestion := nameSuggestion(addr.Name, suggestions)
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
}
@ -624,7 +624,7 @@ func (d *evaluationStateData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.Sourc
return cty.StringVal(filepath.ToSlash(sourceDir)), diags
default:
suggestion := nameSuggestion(addr.Name, []string{"cwd", "module", "root"})
suggestion := didyoumean.NameSuggestion(addr.Name, []string{"cwd", "module", "root"})
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
}
@ -940,25 +940,6 @@ func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfd
}
}
// nameSuggestion tries to find a name from the given slice of suggested names
// that is close to the given name and returns it if found. If no suggestion
// is close enough, returns the empty string.
//
// The suggestions are tried in order, so earlier suggestions take precedence
// if the given string is similar to two or more suggestions.
//
// This function is intended to be used with a relatively-small number of
// suggestions. It's not optimized for hundreds or thousands of them.
func nameSuggestion(given string, suggestions []string) string {
for _, suggestion := range suggestions {
dist := levenshtein.Distance(given, suggestion, nil)
if dist < 3 { // threshold determined experimentally
return suggestion
}
}
return ""
}
// moduleDisplayAddr returns a string describing the given module instance
// address that is appropriate for returning to users in situations where the
// root module is possible. Specifically, it returns "the root module" if the

View File

@ -52,6 +52,11 @@ type ApplyGraphBuilder struct {
// Plan Operation this graph will be used for.
Operation walkOperation
// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference
}
// See GraphBuilder
@ -144,6 +149,11 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
// objects that can belong to modules.
&ModuleExpansionTransformer{Config: b.Config},
// Plug in any external references.
&ExternalReferenceTransformer{
ExternalReferences: b.ExternalReferences,
},
// Connect references so ordering is correct
&ReferenceTransformer{},
&AttachDependenciesTransformer{},

View File

@ -73,6 +73,11 @@ type PlanGraphBuilder struct {
// Plan Operation this graph will be used for.
Operation walkOperation
// ExternalReferences allows the external caller to pass in references to
// nodes that should not be pruned even if they are not referenced within
// the actual graph.
ExternalReferences []*addrs.Reference
// ImportTargets are the list of resources to import.
ImportTargets []*ImportTarget
@ -193,6 +198,11 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer {
// objects that can belong to modules.
&ModuleExpansionTransformer{Concrete: b.ConcreteModule, Config: b.Config},
// Plug in any external references.
&ExternalReferenceTransformer{
ExternalReferences: b.ExternalReferences,
},
&ReferenceTransformer{},
&AttachDependenciesTransformer{},

View File

@ -0,0 +1,31 @@
package terraform
import "github.com/hashicorp/terraform/internal/addrs"
// nodeExternalReference allows external callers (such as the testing framework)
// to provide the list of references they are making into the graph. This
// ensures that Terraform will not remove any nodes from the graph that might
// not be referenced from within a module but are referenced by the currently
// executing test file.
//
// This should only be added to the graph if we are executing the
// `terraform test` command.
type nodeExternalReference struct {
ExternalReferences []*addrs.Reference
}
var (
_ GraphNodeReferencer = (*nodeExternalReference)(nil)
)
// GraphNodeModulePath
func (n *nodeExternalReference) ModulePath() addrs.Module {
// The external references are always made from test files, which currently
// execute as if they are in the root module.
return addrs.RootModule
}
// GraphNodeReferencer
func (n *nodeExternalReference) References() []*addrs.Reference {
return n.ExternalReferences
}

View File

@ -135,7 +135,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config {
t.Fatalf("failed to refresh modules after installation: %s", err)
}
config, diags := loader.LoadConfig(cfgPath)
config, diags := loader.LoadConfigWithTests(cfgPath, "tests")
if diags.HasErrors() {
t.Fatal(diags.Error())
}
@ -232,6 +232,14 @@ func mustProviderConfig(s string) addrs.AbsProviderConfig {
return p
}
func mustReference(s string) *addrs.Reference {
p, diags := addrs.ParseRefStr(s)
if diags.HasErrors() {
panic(diags.Err())
}
return p
}
// HookRecordApplyOrder is a test hook that records the order of applies
// by recording the PreApply event.
type HookRecordApplyOrder struct {

View File

@ -0,0 +1,176 @@
package terraform
import (
"fmt"
"sync"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/moduletest"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// TestContext wraps a Context, and adds in direct values for the current state,
// most recent plan, and configuration.
//
// This combination allows functions called on the TestContext to create a
// complete scope to evaluate test assertions.
type TestContext struct {
*Context
Config *configs.Config
State *states.State
Plan *plans.Plan
Variables InputValues
}
// TestContext creates a TestContext structure that can evaluate test assertions
// against the provided state and plan.
func (c *Context) TestContext(config *configs.Config, state *states.State, plan *plans.Plan, variables InputValues) *TestContext {
return &TestContext{
Context: c,
Config: config,
State: state,
Plan: plan,
Variables: variables,
}
}
// EvaluateAgainstState processes the assertions inside the provided
// configs.TestRun against the embedded state.
//
// The provided plan is import as it is needed to evaluate the `plantimestamp`
// function, but no data or changes from the embedded plan is referenced in
// this function.
func (ctx *TestContext) EvaluateAgainstState(run *moduletest.Run) {
defer ctx.acquireRun("evaluate")()
ctx.evaluate(ctx.State.SyncWrapper(), plans.NewChanges().SyncWrapper(), run, walkApply)
}
// EvaluateAgainstPlan processes the assertions inside the provided
// configs.TestRun against the embedded plan and state.
func (ctx *TestContext) EvaluateAgainstPlan(run *moduletest.Run) {
defer ctx.acquireRun("evaluate")()
ctx.evaluate(ctx.State.SyncWrapper(), ctx.Plan.Changes.SyncWrapper(), run, walkPlan)
}
func (ctx *TestContext) evaluate(state *states.SyncState, changes *plans.ChangesSync, run *moduletest.Run, operation walkOperation) {
data := &evaluationStateData{
Evaluator: &Evaluator{
Operation: operation,
Meta: ctx.meta,
Config: ctx.Config,
Plugins: ctx.plugins,
State: state,
Changes: changes,
VariableValues: func() map[string]map[string]cty.Value {
variables := map[string]map[string]cty.Value{
addrs.RootModule.String(): make(map[string]cty.Value),
}
for name, variable := range ctx.Variables {
variables[addrs.RootModule.String()][name] = variable.Value
}
return variables
}(),
VariableValuesLock: new(sync.Mutex),
PlanTimestamp: ctx.Plan.Timestamp,
},
ModulePath: nil, // nil for the root module
InstanceKeyData: EvalDataForNoInstanceKey,
Operation: operation,
}
scope := &lang.Scope{
Data: data,
BaseDir: ".",
PureOnly: operation != walkApply,
PlanTimestamp: ctx.Plan.Timestamp,
}
// We're going to assume the run has passed, and then if anything fails this
// value will be updated.
run.Status = run.Status.Merge(moduletest.Pass)
// Now validate all the assertions within this run block.
for _, rule := range run.Config.CheckRules {
refs, moreDiags := lang.ReferencesInExpr(rule.Condition)
run.Diagnostics = run.Diagnostics.Append(moreDiags)
moreRefs, moreDiags := lang.ReferencesInExpr(rule.ErrorMessage)
run.Diagnostics = run.Diagnostics.Append(moreDiags)
refs = append(refs, moreRefs...)
hclCtx, moreDiags := scope.EvalContext(refs)
run.Diagnostics = run.Diagnostics.Append(moreDiags)
errorMessage, moreDiags := evalCheckErrorMessage(rule.ErrorMessage, hclCtx)
run.Diagnostics = run.Diagnostics.Append(moreDiags)
runVal, hclDiags := rule.Condition.Value(hclCtx)
run.Diagnostics = run.Diagnostics.Append(hclDiags)
if run.Diagnostics.HasErrors() {
run.Status = run.Status.Merge(moduletest.Error)
continue
}
if runVal.IsNull() {
run.Status = run.Status.Merge(moduletest.Error)
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid condition run",
Detail: "Condition expression must return either true or false, not null.",
Subject: rule.Condition.Range().Ptr(),
Expression: rule.Condition,
EvalContext: hclCtx,
})
continue
}
if !runVal.IsKnown() {
run.Status = run.Status.Merge(moduletest.Error)
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unknown condition run",
Detail: "Condition expression could not be evaluated at this time.",
Subject: rule.Condition.Range().Ptr(),
Expression: rule.Condition,
EvalContext: hclCtx,
})
continue
}
var err error
if runVal, err = convert.Convert(runVal, cty.Bool); err != nil {
run.Status = run.Status.Merge(moduletest.Error)
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid condition run",
Detail: fmt.Sprintf("Invalid condition run value: %s.", tfdiags.FormatError(err)),
Subject: rule.Condition.Range().Ptr(),
Expression: rule.Condition,
EvalContext: hclCtx,
})
continue
}
if runVal.False() {
run.Status = run.Status.Merge(moduletest.Fail)
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Test assertion failed",
Detail: errorMessage,
Subject: rule.Condition.Range().Ptr(),
Expression: rule.Condition,
EvalContext: hclCtx,
})
continue
}
}
}

View File

@ -0,0 +1,467 @@
package terraform
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
ctymsgpack "github.com/zclconf/go-cty/cty/msgpack"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/moduletest"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func TestTestContext_EvaluateAgainstState(t *testing.T) {
tcs := map[string]struct {
configs map[string]string
state *states.State
variables InputValues
provider *MockProvider
expectedDiags []tfdiags.Description
expectedStatus moduletest.Status
}{
"basic_passing": {
configs: map[string]string{
"main.tf": `
resource "test_resource" "a" {
value = "Hello, world!"
}
`,
"main.tftest": `
run "test_case" {
assert {
condition = test_resource.a.value == "Hello, world!"
error_message = "invalid value"
}
}
`,
},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
"value": cty.StringVal("Hello, world!"),
})),
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
provider: &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"value": {
Type: cty.String,
Required: true,
},
},
},
},
},
},
},
expectedStatus: moduletest.Pass,
},
"with_variables": {
configs: map[string]string{
"main.tf": `
variable "value" {
type = string
}
resource "test_resource" "a" {
value = var.value
}
`,
"main.tftest": `
variables {
value = "Hello, world!"
}
run "test_case" {
assert {
condition = test_resource.a.value == var.value
error_message = "invalid value"
}
}
`,
},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
"value": cty.StringVal("Hello, world!"),
})),
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
variables: InputValues{
"value": {
Value: cty.StringVal("Hello, world!"),
},
},
provider: &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"value": {
Type: cty.String,
Required: true,
},
},
},
},
},
},
},
expectedStatus: moduletest.Pass,
},
"basic_failing": {
configs: map[string]string{
"main.tf": `
resource "test_resource" "a" {
value = "Hello, world!"
}
`,
"main.tftest": `
run "test_case" {
assert {
condition = test_resource.a.value == "incorrect!"
error_message = "invalid value"
}
}
`,
},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
"value": cty.StringVal("Hello, world!"),
})),
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
provider: &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"value": {
Type: cty.String,
Required: true,
},
},
},
},
},
},
},
expectedStatus: moduletest.Fail,
expectedDiags: []tfdiags.Description{
{
Summary: "Test assertion failed",
Detail: "invalid value",
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
config := testModuleInline(t, tc.configs)
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider),
},
})
run := moduletest.Run{
Config: config.Module.Tests["main.tftest"].Runs[0],
Name: "test_case",
}
tctx := ctx.TestContext(config, tc.state, &plans.Plan{}, tc.variables)
tctx.EvaluateAgainstState(&run)
if expected, actual := tc.expectedStatus, run.Status; expected != actual {
t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual)
}
compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics)
})
}
}
func TestTestContext_EvaluateAgainstPlan(t *testing.T) {
tcs := map[string]struct {
configs map[string]string
state *states.State
plan *plans.Plan
variables InputValues
provider *MockProvider
expectedDiags []tfdiags.Description
expectedStatus moduletest.Status
}{
"basic_passing": {
configs: map[string]string{
"main.tf": `
resource "test_resource" "a" {
value = "Hello, world!"
}
`,
"main.tftest": `
run "test_case" {
assert {
condition = test_resource.a.value == "Hello, world!"
error_message = "invalid value"
}
}
`,
},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectPlanned,
AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{
"value": cty.String,
}))),
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
plan: &plans.Plan{
Changes: &plans.Changes{
Resources: []*plans.ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ProviderAddr: addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
},
ChangeSrc: plans.ChangeSrc{
Action: plans.Create,
Before: nil,
After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{
"value": cty.StringVal("Hello, world!"),
})),
},
},
},
},
},
provider: &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"value": {
Type: cty.String,
Required: true,
},
},
},
},
},
},
},
expectedStatus: moduletest.Pass,
},
"basic_failing": {
configs: map[string]string{
"main.tf": `
resource "test_resource" "a" {
value = "Hello, world!"
}
`,
"main.tftest": `
run "test_case" {
assert {
condition = test_resource.a.value == "incorrect!"
error_message = "invalid value"
}
}
`,
},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectPlanned,
AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{
"value": cty.String,
}))),
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
plan: &plans.Plan{
Changes: &plans.Changes{
Resources: []*plans.ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "a",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ProviderAddr: addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
},
ChangeSrc: plans.ChangeSrc{
Action: plans.Create,
Before: nil,
After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{
"value": cty.StringVal("Hello, world!"),
})),
},
},
},
},
},
provider: &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_resource": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"value": {
Type: cty.String,
Required: true,
},
},
},
},
},
},
},
expectedStatus: moduletest.Fail,
expectedDiags: []tfdiags.Description{
{
Summary: "Test assertion failed",
Detail: "invalid value",
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
config := testModuleInline(t, tc.configs)
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider),
},
})
run := moduletest.Run{
Config: config.Module.Tests["main.tftest"].Runs[0],
Name: "test_case",
}
tctx := ctx.TestContext(config, tc.state, tc.plan, tc.variables)
tctx.EvaluateAgainstPlan(&run)
if expected, actual := tc.expectedStatus, run.Status; expected != actual {
t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual)
}
compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics)
})
}
}
func compareDiagnosticsFromTestResult(t *testing.T, expected []tfdiags.Description, actual tfdiags.Diagnostics) {
if len(expected) != len(actual) {
t.Errorf("found invalid number of diagnostics, expected %d but found %d", len(expected), len(actual))
}
length := len(expected)
if len(actual) > length {
length = len(actual)
}
for ix := 0; ix < length; ix++ {
if ix >= len(expected) {
t.Errorf("found extra diagnostic at %d:\n%v", ix, actual[ix].Description())
} else if ix >= len(actual) {
t.Errorf("missing diagnostic at %d:\n%v", ix, expected[ix])
} else {
expected := expected[ix]
actual := actual[ix].Description()
if diff := cmp.Diff(expected, actual); len(diff) > 0 {
t.Errorf("found different diagnostics at %d:\nexpected:\n%s\nactual:\n%s\ndiff:%s", ix, expected, actual, diff)
}
}
}
}
func encodeDynamicValue(t *testing.T, value cty.Value) []byte {
data, err := ctymsgpack.Marshal(value, value.Type())
if err != nil {
t.Fatalf("failed to marshal JSON: %s", err)
}
return data
}
func encodeCtyValue(t *testing.T, value cty.Value) []byte {
data, err := ctyjson.Marshal(value, value.Type())
if err != nil {
t.Fatalf("failed to marshal JSON: %s", err)
}
return data
}

View File

@ -0,0 +1,23 @@
package terraform
import "github.com/hashicorp/terraform/internal/addrs"
// ExternalReferenceTransformer will add a GraphNodeReferencer into the graph
// that makes no changes to the graph itself but, by referencing the addresses
// within ExternalReferences, ensures that any temporary nodes that are required
// by an external caller, such as the terraform testing framework, are not
// skipped because they are not referenced from within the module.
type ExternalReferenceTransformer struct {
ExternalReferences []*addrs.Reference
}
func (t *ExternalReferenceTransformer) Transform(g *Graph) error {
if len(t.ExternalReferences) == 0 {
return nil
}
g.Add(&nodeExternalReference{
ExternalReferences: t.ExternalReferences,
})
return nil
}