Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.13/ENHANCEMENTS-20250602-152211.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: '`terraform test`: Test authors can now specify definitions for external variables that are referenced within test files directly within the test file itself.'
time: 2025-06-02T15:22:11.453413+02:00
custom:
Issue: "37195"
5 changes: 5 additions & 0 deletions .changes/v1.13/UPGRADE NOTES-20250602-152009.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: UPGRADE NOTES
body: '`terraform test`: External variables referenced within test files should now be accompanied by a `variable` definition block within the test file. This is optional, but users with complex external variables may see error diagnostics without the additional variable definition.'
time: 2025-06-02T15:20:09.188388+02:00
custom:
Issue: "37195"
30 changes: 17 additions & 13 deletions internal/backend/local/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,26 +110,30 @@ func (runner *TestSuiteRunner) Test() (moduletest.Status, tfdiags.Diagnostics) {
}

file := suite.Files[name]
evalCtx := graph.NewEvalContext(graph.EvalContextOpts{
CancelCtx: runner.CancelledCtx,
StopCtx: runner.StoppedCtx,
Verbose: runner.Verbose,
Render: runner.View,
})

// TODO(liamcervante): Do the variables in the EvalContextTransformer
// as well as the run blocks.

currentGlobalVariables := runner.GlobalVariables
if filepath.Dir(file.Name) == runner.TestingDirectory {
// If the file is in the test directory, we'll use the union of the
// global variables and the global test variables.
currentGlobalVariables = testDirectoryGlobalVariables
}
evalCtx.VariableCaches = hcltest.NewVariableCaches(func(vc *hcltest.VariableCaches) {
maps.Copy(vc.GlobalVariables, currentGlobalVariables)
vc.FileVariables = file.Config.Variables

evalCtx := graph.NewEvalContext(graph.EvalContextOpts{
CancelCtx: runner.CancelledCtx,
StopCtx: runner.StoppedCtx,
Verbose: runner.Verbose,
Render: runner.View,
VariableCache: &hcltest.VariableCache{

// TODO(liamcervante): Do the variables in the EvalContextTransformer
// as well as the run blocks.

ExternalVariableValues: currentGlobalVariables,
TestFileVariableDefinitions: file.Config.VariableDefinitions,
TestFileVariableExpressions: file.Config.Variables,
},
})

fileRunner := &TestFileRunner{
Suite: runner,
EvalContext: evalCtx,
Expand Down Expand Up @@ -248,7 +252,7 @@ func (runner *TestFileRunner) Test(file *moduletest.File) {
// Build the graph for the file.
b := graph.TestGraphBuilder{
File: file,
GlobalVars: runner.EvalContext.VariableCaches.GlobalVariables,
GlobalVars: runner.EvalContext.VariableCache.ExternalVariableValues,
ContextOpts: runner.Suite.Opts,
}
g, diags := b.Build()
Expand Down
5 changes: 5 additions & 0 deletions internal/command/test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ func TestTest_Runs(t *testing.T) {
expectedOut: []string{"1 passed, 0 failed."},
code: 0,
},
"with-default-variables": {
args: []string{"-var=input_two=universe"},
expectedOut: []string{"1 passed, 0 failed."},
code: 0,
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
Expand Down
12 changes: 12 additions & 0 deletions internal/command/testdata/test/with-default-variables/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

variable "input_one" {
type = string
}

variable "input_two" {
type = string
}

resource "test_resource" "resource" {
value = "${var.input_one} - ${var.input_two}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

variable "input_one" {
type = string
default = "hello"
}

variable "input_two" {
type = string
default = "world" // we will override this an external value
}

run "test" {
assert {
condition = test_resource.resource.value == "hello - universe"
error_message = "bad concatenation"
}
}
51 changes: 49 additions & 2 deletions internal/configs/test_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ const (
// A test file is made up of a sequential list of run blocks, each designating
// a command to execute and a series of validations to check after the command.
type TestFile struct {

// VariableDefinitions allows users to specify variables that should be
// provided externally (eg. from the command line or external files).
//
// This conflicts with the Variables block. Variables specified in the
// VariableDefinitions cannot also be specified within the Variables block.
VariableDefinitions map[string]*Variable

// Variables defines a set of global variable definitions that should be set
// for every run block within the test file.
Variables map[string]hcl.Expression
Expand Down Expand Up @@ -327,8 +335,9 @@ type TestRunOptions struct {
func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) {
var diags hcl.Diagnostics
tf := &TestFile{
Providers: make(map[string]*Provider),
Overrides: addrs.MakeMap[addrs.Targetable, *Override](),
VariableDefinitions: make(map[string]*Variable),
Providers: make(map[string]*Provider),
Overrides: addrs.MakeMap[addrs.Targetable, *Override](),
}

// we need to retrieve the file config block first, because the run blocks
Expand Down Expand Up @@ -370,6 +379,31 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) {
}
runBlockNames[run.Name] = run.DeclRange

case "variable":
variable, variableDiags := decodeVariableBlock(block, false)
diags = append(diags, variableDiags...)
if !variableDiags.HasErrors() {
if existing, exists := tf.VariableDefinitions[variable.Name]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate \"variable\" block names",
Detail: fmt.Sprintf("This test file already has a variable named %s defined at %s.", variable.Name, existing.DeclRange),
Subject: variable.DeclRange.Ptr(),
})
continue
}
tf.VariableDefinitions[variable.Name] = variable

if existing, exists := tf.Variables[variable.Name]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate \"variable\" block names",
Detail: fmt.Sprintf("This test file already has a variable named %s defined at %s.", variable.Name, existing.Range()),
Subject: variable.DeclRange.Ptr(),
})
}
}

case "variables":
if tf.Variables != nil {
diags = append(diags, &hcl.Diagnostic{
Expand All @@ -388,6 +422,15 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) {
diags = append(diags, varsDiags...)
for _, v := range vars {
tf.Variables[v.Name] = v.Expr

if existing, exists := tf.VariableDefinitions[v.Name]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate \"variable\" block names",
Detail: fmt.Sprintf("This test file already has a variable named %s defined at %s.", v.Name, v.Range),
Subject: existing.DeclRange.Ptr(),
})
}
}
case "provider":
provider, providerDiags := decodeProviderBlock(block, true)
Expand Down Expand Up @@ -888,6 +931,10 @@ var testFileSchema = &hcl.BodySchema{
Type: "mock_provider",
LabelNames: []string{"name"},
},
{
Type: "variable",
LabelNames: []string{"name"},
},
{
Type: "variables",
},
Expand Down
17 changes: 7 additions & 10 deletions internal/moduletest/graph/eval_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type TestFileState struct {
// within the suite.
// The struct provides concurrency-safe access to the various maps it contains.
type EvalContext struct {
VariableCaches *hcltest.VariableCaches
VariableCache *hcltest.VariableCache

// runOutputs is a mapping from run addresses to cty object values
// representing the collected output values from the module under test.
Expand Down Expand Up @@ -84,10 +84,11 @@ type EvalContext struct {
}

type EvalContextOpts struct {
Verbose bool
Render views.Test
CancelCtx context.Context
StopCtx context.Context
Verbose bool
Render views.Test
CancelCtx context.Context
StopCtx context.Context
VariableCache *hcltest.VariableCache
}

// NewEvalContext constructs a new graph evaluation context for use in
Expand All @@ -104,7 +105,7 @@ func NewEvalContext(opts EvalContextOpts) *EvalContext {
providersLock: sync.Mutex{},
FileStates: make(map[string]*TestFileState),
stateLock: sync.Mutex{},
VariableCaches: hcltest.NewVariableCaches(),
VariableCache: opts.VariableCache,
cancelContext: cancelCtx,
cancelFunc: cancel,
stopContext: stopCtx,
Expand Down Expand Up @@ -326,10 +327,6 @@ func (ec *EvalContext) GetOutputs() map[addrs.Run]cty.Value {
return outputCopy
}

func (ec *EvalContext) GetCache(run *moduletest.Run) *hcltest.VariableCache {
return ec.VariableCaches.GetCache(run.Name, run.ModuleConfig)
}

// ProviderExists returns true if the provider exists for the run inside the context.
func (ec *EvalContext) ProviderExists(run *moduletest.Run, key string) bool {
ec.providersLock.Lock()
Expand Down
4 changes: 2 additions & 2 deletions internal/moduletest/graph/transform_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func TransformConfigForRun(ctx *EvalContext, run *moduletest.Run, file *modulete
AliasRange: ref.InChild.AliasRange,
Config: &hcltest.ProviderConfig{
Original: testProvider.Config,
VariableCache: ctx.GetCache(run),
VariableCache: ctx.VariableCache,
AvailableRunOutputs: runOutputs,
},
Mock: testProvider.Mock,
Expand All @@ -114,7 +114,7 @@ func TransformConfigForRun(ctx *EvalContext, run *moduletest.Run, file *modulete
AliasRange: provider.AliasRange,
Config: &hcltest.ProviderConfig{
Original: provider.Config,
VariableCache: ctx.GetCache(run),
VariableCache: ctx.VariableCache,
AvailableRunOutputs: runOutputs,
},
Mock: provider.Mock,
Expand Down
56 changes: 30 additions & 26 deletions internal/moduletest/graph/variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,8 @@ func (n *NodeTestRun) GetVariables(ctx *EvalContext, includeWarnings bool) (terr
refs, refDiags := langrefs.ReferencesInExpr(addrs.ParseRefFromTestingScope, expr)
for _, ref := range refs {
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
cache := ctx.GetCache(run)

value, valueDiags := cache.GetFileVariable(addr.Name)
diags = diags.Append(valueDiags)
if value != nil {
requiredValues[addr.Name] = value.Value
continue
}

// Otherwise, it might be a global variable.
value, valueDiags = cache.GetGlobalVariable(addr.Name)
diags = diags.Append(valueDiags)
if value != nil {
if value, valueDiags := ctx.VariableCache.GetVariableValue(addr.Name); value != nil {
diags = diags.Append(valueDiags)
requiredValues[addr.Name] = value.Value
continue
}
Expand Down Expand Up @@ -118,18 +107,14 @@ func (n *NodeTestRun) GetVariables(ctx *EvalContext, includeWarnings bool) (terr
continue
}

// Otherwise, we'll get it from the cache as a file-level or global
// variable.
cache := ctx.GetCache(run)

value, valueDiags := cache.GetFileVariable(variable)
diags = diags.Append(valueDiags)
if value != nil {
values[variable] = value
if _, exists := run.ModuleConfig.Module.Variables[variable]; exists {
// We'll deal with this later.
continue
}

value, valueDiags = cache.GetGlobalVariable(variable)
// Otherwise, we'll get it from the cache as a file-level or global
// variable.
value, valueDiags := ctx.VariableCache.GetVariableValue(variable)
diags = diags.Append(valueDiags)
if value != nil {
values[variable] = value
Expand All @@ -143,13 +128,32 @@ func (n *NodeTestRun) GetVariables(ctx *EvalContext, includeWarnings bool) (terr

for name, variable := range run.ModuleConfig.Module.Variables {
if _, exists := values[name]; exists {
// Then we've provided a variable for this. It's all good.
// Then we've provided a variable for this explicitly. It's all
// good.
continue
}

// Otherwise, we're going to give these variables a value. They'll be
// processed by the Terraform graph and provided a default value later
// if they have one.
// The user might have provided a value for this externally or at the
// file level, so we can also just pass it through.

if ctx.VariableCache.HasVariableDefinition(variable.Name) {
if value, valueDiags := ctx.VariableCache.GetVariableValue(variable.Name); value != nil {
diags = diags.Append(valueDiags)
values[name] = value
continue
}
} else {
if value, valueDiags := ctx.VariableCache.EvaluateExternalVariable(name, variable); value != nil {
diags = diags.Append(valueDiags)
values[name] = value
continue
}
}

// If all else fails, these variables may have default values set within
// the to-be-executed Terraform config. We'll put in placeholder values
// if that is the case, otherwise add a diagnostic early to avoid
// executing something we know will fail.

if variable.Required() {
diags = diags.Append(&hcl.Diagnostic{
Expand Down
4 changes: 2 additions & 2 deletions internal/moduletest/hcl/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,15 @@ func (p *ProviderConfig) transformAttributes(originals hcl.Attributes) (hcl.Attr
refs, _ := langrefs.ReferencesInExpr(addrs.ParseRefFromTestingScope, original.Expr)
for _, ref := range refs {
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
value, valueDiags := p.VariableCache.GetFileVariable(addr.Name)
value, valueDiags := p.VariableCache.GetVariableValue(addr.Name)
diags = append(diags, valueDiags.ToHCL()...)
if value != nil {
availableVariables[addr.Name] = value.Value
continue
}

// If the variable wasn't a file variable, it might be a global.
value, valueDiags = p.VariableCache.GetGlobalVariable(addr.Name)
value, valueDiags = p.VariableCache.GetVariableValue(addr.Name)
diags = append(diags, valueDiags.ToHCL()...)
if value != nil {
availableVariables[addr.Name] = value.Value
Expand Down
10 changes: 5 additions & 5 deletions internal/moduletest/hcl/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,19 @@ func TestProviderConfig(t *testing.T) {
outputs[addr] = cty.ObjectVal(attrs)
}

variableCaches := NewVariableCaches(func(vc *VariableCaches) {
vc.FileVariables = func() map[string]hcl.Expression {
variableCache := &VariableCache{
TestFileVariableExpressions: func() map[string]hcl.Expression {
variables := make(map[string]hcl.Expression)
for name, value := range tc.variables {
variables[name] = hcl.StaticExpr(value, hcl.Range{})
}
return variables
}()
})
}(),
}

config := ProviderConfig{
Original: file.Body,
VariableCache: variableCaches.GetCache("test", nil),
VariableCache: variableCache,
AvailableRunOutputs: outputs,
}

Expand Down
Loading