diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 4b8e4c5380f6..1e7352a05733 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "sort" "strings" "testing" @@ -52,6 +53,7 @@ func TestTest_Runs(t *testing.T) { initCode int skip bool description string + selectFiles []string }{ "simple_pass": { expectedOut: []string{"1 passed, 0 failed."}, @@ -297,7 +299,6 @@ func TestTest_Runs(t *testing.T) { }, "mocking-invalid": { expectedErr: []string{ - "Invalid outputs attribute", "The override_during attribute must be a value of plan or apply.", }, initCode: 1, @@ -418,6 +419,19 @@ func TestTest_Runs(t *testing.T) { "no-tests": { code: 0, }, + "simple_pass_function": { + expectedOut: []string{"2 passed, 0 failed."}, + code: 0, + expectedResourceCount: 0, + }, + "mocking-invalid-outputs": { + override: "mocking-invalid", + expectedErr: []string{ + "Invalid outputs attribute", + }, + selectFiles: []string{"module_mocked_invalid_type.tftest.hcl"}, + code: 1, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -441,6 +455,28 @@ func TestTest_Runs(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath(path.Join("test", file)), td) + if len(tc.selectFiles) > 0 { + dirs, _ := os.ReadDir(td) + dirs2, _ := os.ReadDir(filepath.Join(td, "tests")) + for _, dir := range dirs { + dirName := dir.Name() + if !slices.Contains(tc.selectFiles, dirName) && strings.HasSuffix(dirName, "tftest.hcl") { + err := os.Remove(filepath.Join(td, dirName)) + if err != nil { + t.Errorf("failed to remove file %s: %v", dirName, err) + } + } + } + for _, dir := range dirs2 { + dirName := dir.Name() + if !slices.Contains(tc.selectFiles, dirName) && strings.HasSuffix(dirName, "tftest.hcl") { + err := os.Remove(filepath.Join(td, "tests", dirName)) + if err != nil { + t.Errorf("failed to remove file %s: %v", dirName, err) + } + } + } + } t.Chdir(td) store := &testing_command.ResourceStore{ diff --git a/internal/command/testdata/test/simple_pass_function/main.tf b/internal/command/testdata/test/simple_pass_function/main.tf new file mode 100644 index 000000000000..6675af5dfb0d --- /dev/null +++ b/internal/command/testdata/test/simple_pass_function/main.tf @@ -0,0 +1,7 @@ +resource "test_resource" "foo" { + value = "foo" +} + +resource "test_resource" "bar" { + value = "bar" +} diff --git a/internal/command/testdata/test/simple_pass_function/main.tftest.hcl b/internal/command/testdata/test/simple_pass_function/main.tftest.hcl new file mode 100644 index 000000000000..768bd51edb12 --- /dev/null +++ b/internal/command/testdata/test/simple_pass_function/main.tftest.hcl @@ -0,0 +1,28 @@ +mock_provider "test" { + mock_resource "test_resource" { + defaults = { + id = format("f-%s", "foo") + } + } +} + +override_resource { + target = test_resource.bar + values = { + id = format("%s-%s", uuid(), "bar") + } +} + +run "validate_test_resource_foo" { + assert { + condition = test_resource.foo.id == "f-foo" + error_message = "invalid value" + } +} + +run "validate_test_resource_bar" { + assert { + condition = length(test_resource.bar.id) > 10 + error_message = "invalid value" + } +} diff --git a/internal/configs/mock_provider.go b/internal/configs/mock_provider.go index 97118a9b237c..15c386525d64 100644 --- a/internal/configs/mock_provider.go +++ b/internal/configs/mock_provider.go @@ -181,6 +181,7 @@ type MockResource struct { Type string Defaults cty.Value + RawValue hcl.Expression // UseForPlan is true if the values should be computed during the planning // phase. @@ -208,6 +209,11 @@ type Override struct { Target *addrs.Target Values cty.Value + BlockName string + + // The raw expression of the values/outputs block + RawValue hcl.Expression + // UseForPlan is true if the values should be computed during the planning // phase. UseForPlan bool @@ -325,10 +331,8 @@ func decodeMockResourceBlock(block *hcl.Block, useForPlanDefault bool) (*MockRes } if defaults, exists := content.Attributes["defaults"]; exists { - var defaultDiags hcl.Diagnostics resource.DefaultsRange = defaults.Range - resource.Defaults, defaultDiags = defaults.Expr.Value(nil) - diags = append(diags, defaultDiags...) + resource.RawValue = defaults.Expr } else { // It's fine if we don't have any defaults, just means we'll generate // values for everything ourselves. @@ -453,6 +457,7 @@ func decodeOverrideBlock(block *hcl.Block, attributeName string, blockName strin Source: source, Range: block.DefRange, TypeRange: block.TypeRange, + BlockName: blockName, } if target, exists := content.Attributes["target"]; exists { @@ -472,41 +477,15 @@ func decodeOverrideBlock(block *hcl.Block, attributeName string, blockName strin Subject: override.Range.Ptr(), }) } - if attribute, exists := content.Attributes[attributeName]; exists { - var valueDiags hcl.Diagnostics override.ValuesRange = attribute.Range - override.Values, valueDiags = attribute.Expr.Value(nil) - diags = append(diags, valueDiags...) - } else { - // It's fine if we don't have any values, just means we'll generate - // values for everything ourselves. We set this to an empty object so - // it's equivalent to `values = {}` which makes later processing easier. - override.Values = cty.EmptyObjectVal + override.RawValue = attribute.Expr } useForPlan, useForPlanDiags := useForPlan(content, useForPlanDefault) diags = append(diags, useForPlanDiags...) override.UseForPlan = useForPlan - if !override.Values.Type().IsObjectType() { - - var attributePreposition string - switch attributeName { - case "outputs": - attributePreposition = "an" - default: - attributePreposition = "a" - } - - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: fmt.Sprintf("Invalid %s attribute", attributeName), - Detail: fmt.Sprintf("%s blocks must specify %s %s attribute that is an object.", blockName, attributePreposition, attributeName), - Subject: override.ValuesRange.Ptr(), - }) - } - return override, diags } diff --git a/internal/moduletest/graph/apply.go b/internal/moduletest/graph/apply.go index 0c9dfa21e9eb..7d46f50f3be6 100644 --- a/internal/moduletest/graph/apply.go +++ b/internal/moduletest/graph/apply.go @@ -23,7 +23,7 @@ import ( // testApply defines how to execute a run block representing an apply command // // See also: (n *NodeTestRun).testPlan -func (n *NodeTestRun) testApply(ctx *EvalContext, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, mocks map[addrs.RootProviderConfig]*configs.MockData, waiter *operationWaiter) { +func (n *NodeTestRun) testApply(ctx *EvalContext, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, waiter *operationWaiter) { file, run := n.File(), n.run config := run.ModuleConfig key := n.run.Config.StateKey @@ -37,7 +37,7 @@ func (n *NodeTestRun) testApply(ctx *EvalContext, variables terraform.InputValue tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) // execute the terraform plan operation - _, plan, planDiags := plan(ctx, tfCtx, file.Config, run.Config, run.ModuleConfig, setVariables, providers, mocks, waiter) + _, plan, planDiags := plan(ctx, tfCtx, file.Config, run.Config, run.ModuleConfig, setVariables, providers, waiter) // Any error during the planning prevents our apply from // continuing which is an error. diff --git a/internal/moduletest/graph/eval_context.go b/internal/moduletest/graph/eval_context.go index dc356639a569..5e7b92fd2e7b 100644 --- a/internal/moduletest/graph/eval_context.go +++ b/internal/moduletest/graph/eval_context.go @@ -23,6 +23,7 @@ import ( "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/langrefs" "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/moduletest/mocking" teststates "github.com/hashicorp/terraform/internal/moduletest/states" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" @@ -89,6 +90,9 @@ type EvalContext struct { // repair is true if the test suite is being run in cleanup repair mode. // It is only set when in test cleanup mode. repair bool + + overrides map[string]*mocking.Overrides + overrideLock sync.Mutex } type EvalContextOpts struct { @@ -135,6 +139,7 @@ func NewEvalContext(opts EvalContextOpts) *EvalContext { mode: opts.Mode, deferralAllowed: opts.DeferralAllowed, evalSem: terraform.NewSemaphore(opts.Concurrency), + overrides: make(map[string]*mocking.Overrides), } } @@ -720,6 +725,18 @@ func (ec *EvalContext) PriorRunsCompleted(runs map[string]*moduletest.Run) bool return true } +func (ec *EvalContext) SetOverrides(run *moduletest.Run, overrides *mocking.Overrides) { + ec.overrideLock.Lock() + defer ec.overrideLock.Unlock() + ec.overrides[run.Name] = overrides +} + +func (ec *EvalContext) GetOverrides(runName string) *mocking.Overrides { + ec.overrideLock.Lock() + defer ec.overrideLock.Unlock() + return ec.overrides[runName] +} + // evaluationData augments an underlying lang.Data -- presumably resulting // from a terraform.Context.PlanAndEval or terraform.Context.ApplyAndEval call -- // with results from prior runs that should therefore be available when diff --git a/internal/moduletest/graph/node_provider.go b/internal/moduletest/graph/node_provider.go index d3bba441bc3f..eb79da5a5119 100644 --- a/internal/moduletest/graph/node_provider.go +++ b/internal/moduletest/graph/node_provider.go @@ -26,11 +26,12 @@ var ( type NodeProviderConfigure struct { name, alias string - Addr addrs.RootProviderConfig - File *moduletest.File - Config *configs.Provider - Provider providers.Interface - Schema providers.GetProviderSchemaResponse + Addr addrs.RootProviderConfig + File *moduletest.File + Config *configs.Provider + Provider providers.Interface + Schema providers.GetProviderSchemaResponse + MockProvider *providers.Mock } func (n *NodeProviderConfigure) Name() string { @@ -78,6 +79,19 @@ func (n *NodeProviderConfigure) Execute(ctx *EvalContext) { return } + if n.MockProvider != nil { + for _, res := range n.MockProvider.Data.MockResources { + values, hclDiags := res.RawValue.Value(hclContext) + n.File.Diagnostics.Append(hclDiags) + res.Defaults = values + } + for _, res := range n.MockProvider.Data.MockDataSources { + values, hclDiags := res.RawValue.Value(hclContext) + n.File.Diagnostics.Append(hclDiags) + res.Defaults = values + } + } + body, hclDiags := hcldec.Decode(n.Config.Config, spec, hclContext) n.File.AppendDiagnostics(moreDiags) if hclDiags.HasErrors() { diff --git a/internal/moduletest/graph/node_state_cleanup.go b/internal/moduletest/graph/node_state_cleanup.go index 96072bbe1cc4..fb5b95ef2f2d 100644 --- a/internal/moduletest/graph/node_state_cleanup.go +++ b/internal/moduletest/graph/node_state_cleanup.go @@ -117,7 +117,7 @@ func (n *NodeStateCleanup) restore(ctx *EvalContext, file *configs.TestFile, run // we ignore the diagnostics from here, because we will have reported them // during the initial execution of the run block and we would not have // executed the run block if there were any errors. - providers, mocks, _ := getProviders(ctx, file, run, module) + providers, _, _ := getProviders(ctx, file, run, module) // During the destroy operation, we don't add warnings from this operation. // Anything that would have been reported here was already reported during @@ -128,7 +128,7 @@ func (n *NodeStateCleanup) restore(ctx *EvalContext, file *configs.TestFile, run planOpts := &terraform.PlanOpts{ Mode: plans.NormalMode, SetVariables: setVariables, - Overrides: mocking.PackageOverrides(run, file, mocks), + Overrides: ctx.GetOverrides(run.Name), ExternalProviders: providers, SkipRefresh: true, OverridePreventDestroy: true, @@ -177,10 +177,20 @@ func (n *NodeStateCleanup) destroy(ctx *EvalContext, file *configs.TestFile, run // we care about. setVariables, _, _ := FilterVariablesToModule(module, variables) + // TODO: Do we need the exact same overrides used in the plan? + hclctx, diags := ctx.HclContext(nil) + if diags != nil { + return state, diags + } + overrides, diags := mocking.PackageOverrides(hclctx, run, file, mocks) + if diags != nil { + return state, diags + } + planOpts := &terraform.PlanOpts{ Mode: plans.DestroyMode, SetVariables: setVariables, - Overrides: mocking.PackageOverrides(run, file, mocks), + Overrides: overrides, ExternalProviders: providers, SkipRefresh: true, OverridePreventDestroy: true, diff --git a/internal/moduletest/graph/node_test_run.go b/internal/moduletest/graph/node_test_run.go index 39413f383dcb..9fe268adec27 100644 --- a/internal/moduletest/graph/node_test_run.go +++ b/internal/moduletest/graph/node_test_run.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" @@ -161,6 +162,22 @@ func (n *NodeTestRun) execute(ctx *EvalContext, waiter *operationWaiter) { return } + // Evaluate the override blocks + hclCtx, diags := ctx.HclContext(nil) + if diags != nil { + run.Status = moduletest.Error + run.Diagnostics = run.Diagnostics.Append(diags) + return + } + + overrides, diags := mocking.PackageOverrides(hclCtx, run.Config, file.Config, mocks) + if diags != nil { + run.Status = moduletest.Error + run.Diagnostics = run.Diagnostics.Append(diags) + return + } + ctx.SetOverrides(n.run, overrides) + n.testValidate(providers, waiter) if run.Diagnostics.HasErrors() { return @@ -174,9 +191,9 @@ func (n *NodeTestRun) execute(ctx *EvalContext, waiter *operationWaiter) { } if run.Config.Command == configs.PlanTestCommand { - n.testPlan(ctx, variables, providers, mocks, waiter) + n.testPlan(ctx, variables, providers, waiter) } else { - n.testApply(ctx, variables, providers, mocks, waiter) + n.testApply(ctx, variables, providers, waiter) } } diff --git a/internal/moduletest/graph/plan.go b/internal/moduletest/graph/plan.go index 17eebf233127..434537141b45 100644 --- a/internal/moduletest/graph/plan.go +++ b/internal/moduletest/graph/plan.go @@ -14,7 +14,6 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/moduletest" - "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/terraform" @@ -24,7 +23,7 @@ import ( // testPlan defines how to execute a run block representing a plan command // // See also: (n *NodeTestRun).testApply -func (n *NodeTestRun) testPlan(ctx *EvalContext, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, mocks map[addrs.RootProviderConfig]*configs.MockData, waiter *operationWaiter) { +func (n *NodeTestRun) testPlan(ctx *EvalContext, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, waiter *operationWaiter) { file, run := n.File(), n.run config := run.ModuleConfig @@ -37,7 +36,7 @@ func (n *NodeTestRun) testPlan(ctx *EvalContext, variables terraform.InputValues tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) // execute the terraform plan operation - planScope, plan, originalDiags := plan(ctx, tfCtx, file.Config, run.Config, run.ModuleConfig, setVariables, providers, mocks, waiter) + planScope, plan, originalDiags := plan(ctx, tfCtx, file.Config, run.Config, run.ModuleConfig, setVariables, providers, waiter) // We exclude the diagnostics that are expected to fail from the plan // diagnostics, and if an expected failure is not found, we add a new error diagnostic. planDiags := moduletest.ValidateExpectedFailures(run.Config, originalDiags) @@ -90,7 +89,7 @@ func (n *NodeTestRun) testPlan(ctx *EvalContext, variables terraform.InputValues run.Outputs = outputVals } -func plan(ctx *EvalContext, tfCtx *terraform.Context, file *configs.TestFile, run *configs.TestRun, module *configs.Config, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, mocks map[addrs.RootProviderConfig]*configs.MockData, waiter *operationWaiter) (*lang.Scope, *plans.Plan, tfdiags.Diagnostics) { +func plan(ctx *EvalContext, tfCtx *terraform.Context, file *configs.TestFile, run *configs.TestRun, module *configs.Config, variables terraform.InputValues, providers map[addrs.RootProviderConfig]providers.Interface, waiter *operationWaiter) (*lang.Scope, *plans.Plan, tfdiags.Diagnostics) { log.Printf("[TRACE] TestFileRunner: called plan for %s", run.Name) var diags tfdiags.Diagnostics @@ -133,7 +132,7 @@ func plan(ctx *EvalContext, tfCtx *terraform.Context, file *configs.TestFile, ru SetVariables: variables, ExternalReferences: references, ExternalProviders: providers, - Overrides: mocking.PackageOverrides(run, file, mocks), + Overrides: ctx.GetOverrides(run.Name), DeferralAllowed: ctx.deferralAllowed, AllowRootEphemeralOutputs: true, } diff --git a/internal/moduletest/graph/transform_providers.go b/internal/moduletest/graph/transform_providers.go index 34a1ddd028bb..4c2dd837d52d 100644 --- a/internal/moduletest/graph/transform_providers.go +++ b/internal/moduletest/graph/transform_providers.go @@ -44,12 +44,14 @@ func (t *TestProvidersTransformer) Transform(g *terraform.Graph) error { if err != nil { return fmt.Errorf("could not create provider instance: %w", err) } + var mock *providers.Mock if config.Mock { - impl = &providers.Mock{ + mock = &providers.Mock{ Provider: impl, Data: config.MockData, } + impl = mock } addr := addrs.RootProviderConfig{ @@ -58,13 +60,14 @@ func (t *TestProvidersTransformer) Transform(g *terraform.Graph) error { } configure := &NodeProviderConfigure{ - name: config.Name, - alias: config.Alias, - Addr: addr, - File: t.File, - Config: config, - Provider: impl, - Schema: impl.GetProviderSchema(), + name: config.Name, + alias: config.Alias, + Addr: addr, + File: t.File, + Config: config, + Provider: impl, + Schema: impl.GetProviderSchema(), + MockProvider: mock, } g.Add(configure) diff --git a/internal/moduletest/mocking/overrides.go b/internal/moduletest/mocking/overrides.go index 6e02bd411fc4..05911ddef95a 100644 --- a/internal/moduletest/mocking/overrides.go +++ b/internal/moduletest/mocking/overrides.go @@ -4,8 +4,13 @@ package mocking import ( + "fmt" + + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" ) // Overrides contains a summary of all the overrides that should apply for a @@ -18,16 +23,39 @@ type Overrides struct { localOverrides addrs.Map[addrs.Targetable, *configs.Override] } -func PackageOverrides(run *configs.TestRun, file *configs.TestFile, mocks map[addrs.RootProviderConfig]*configs.MockData) *Overrides { +func PackageOverrides(ctx *hcl.EvalContext, run *configs.TestRun, file *configs.TestFile, mocks map[addrs.RootProviderConfig]*configs.MockData) (*Overrides, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics overrides := &Overrides{ providerOverrides: make(map[addrs.RootProviderConfig]addrs.Map[addrs.Targetable, *configs.Override]), localOverrides: addrs.MakeMap[addrs.Targetable, *configs.Override](), } + evalAndPut := func(container addrs.Map[addrs.Targetable, *configs.Override], target addrs.Targetable, override *configs.Override) tfdiags.Diagnostics { + var hclDiags hcl.Diagnostics + values := cty.EmptyObjectVal + if override.RawValue != nil { + values, hclDiags = override.RawValue.Value(ctx) + if values != cty.NilVal && !values.Type().IsObjectType() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid outputs attribute", + Detail: fmt.Sprintf("%s blocks must specify an outputs attribute that is an object.", override.BlockName), + Subject: override.ValuesRange.Ptr(), + }) + return diags + } + } + override.Values = values + container.Put(target, override) + return diags.Append(hclDiags) + } + // The run block overrides have the highest priority, we always include all // of them. for _, elem := range run.Overrides.Elems { - overrides.localOverrides.PutElement(elem) + if diags := evalAndPut(overrides.localOverrides, elem.Key, elem.Value); diags.HasErrors() { + return overrides, diags + } } // The file overrides are second, we include these as long as there isn't @@ -41,7 +69,9 @@ func PackageOverrides(run *configs.TestRun, file *configs.TestFile, mocks map[ad continue } - overrides.localOverrides.PutElement(elem) + if diags := evalAndPut(overrides.localOverrides, elem.Key, elem.Value); diags.HasErrors() { + return overrides, diags + } } // Finally, we want to include the overrides for any mock providers we have. @@ -58,11 +88,14 @@ func PackageOverrides(run *configs.TestRun, file *configs.TestFile, mocks map[ad if _, exists := overrides.providerOverrides[key]; !exists { overrides.providerOverrides[key] = addrs.MakeMap[addrs.Targetable, *configs.Override]() } - overrides.providerOverrides[key].PutElement(elem) + + if diags := evalAndPut(overrides.providerOverrides[key], elem.Key, elem.Value); diags.HasErrors() { + return overrides, diags + } } } - return overrides + return overrides, diags } // IsOverridden returns true if the module is either overridden directly or diff --git a/internal/moduletest/mocking/overrides_test.go b/internal/moduletest/mocking/overrides_test.go index 5788d64c4013..157798bcb09a 100644 --- a/internal/moduletest/mocking/overrides_test.go +++ b/internal/moduletest/mocking/overrides_test.go @@ -75,7 +75,7 @@ func TestPackageOverrides(t *testing.T) { }, } - overrides := PackageOverrides(run, file, mocks) + overrides, _ := PackageOverrides(nil, run, file, mocks) // We now expect that the run and file overrides took precedence. first, fOk := overrides.GetResourceOverride(primary, addrs.AbsProviderConfig{