| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package command | |
| import ( | |
| "context" | |
| "fmt" | |
| "maps" | |
| "path/filepath" | |
| "slices" | |
| "sort" | |
| "strings" | |
| "time" | |
| "github.com/hashicorp/hcl/v2" | |
| backendInit "github.com/hashicorp/terraform/internal/backend/init" | |
| "github.com/hashicorp/terraform/internal/backend/local" | |
| "github.com/hashicorp/terraform/internal/cloud" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/command/jsonformat" | |
| "github.com/hashicorp/terraform/internal/command/junit" | |
| "github.com/hashicorp/terraform/internal/command/views" | |
| "github.com/hashicorp/terraform/internal/configs" | |
| "github.com/hashicorp/terraform/internal/logging" | |
| "github.com/hashicorp/terraform/internal/moduletest" | |
| "github.com/hashicorp/terraform/internal/terraform" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| type TestCommand struct { | |
| Meta | |
| } | |
| func (c *TestCommand) Help() string { | |
| helpText := ` | |
| Usage: terraform [global options] test [options] | |
| Executes automated integration tests against the current Terraform | |
| configuration. | |
| Terraform will search for .tftest.hcl files within the current configuration | |
| and testing directories. Terraform will then execute the testing run blocks | |
| within any testing files in order, and verify conditional checks and | |
| assertions against the created infrastructure. | |
| This command creates real infrastructure and will attempt to clean up the | |
| testing infrastructure on completion. Monitor the output carefully to ensure | |
| this cleanup process is successful. | |
| Options: | |
| -cloud-run=source If specified, Terraform will execute this test run | |
| remotely using HCP Terraform or Terraform Enterprise. | |
| You must specify the source of a module registered in | |
| a private module registry as the argument to this flag. | |
| This allows Terraform to associate the cloud run with | |
| the correct HCP Terraform or Terraform Enterprise module | |
| and organization. | |
| -filter=testfile If specified, Terraform will only execute the test files | |
| specified by this flag. You can use this option multiple | |
| times to execute more than one test file. | |
| -json If specified, machine readable output will be printed in | |
| JSON format | |
| -junit-xml=path Saves a test report in JUnit XML format to the specified | |
| file. This is currently incompatible with remote test | |
| execution using the -cloud-run option. The file path | |
| must be relative or absolute. | |
| -no-color If specified, output won't contain any color. | |
| -parallelism=n Limit the number of concurrent operations within the | |
| plan/apply operation of a test run. Defaults to 10. | |
| -test-directory=path Set the Terraform test directory, defaults to "tests". | |
| -var 'foo=bar' Set a value for one of the input variables in the root | |
| module of the configuration. Use this option more than | |
| once to set more than one variable. | |
| -var-file=filename Load variable values from the given file, in addition | |
| to the default files terraform.tfvars and *.auto.tfvars. | |
| Use this option more than once to include more than one | |
| variables file. | |
| -verbose Print the plan or state for each test run block as it | |
| executes. | |
| ` | |
| return strings.TrimSpace(helpText) | |
| } | |
| func (c *TestCommand) Synopsis() string { | |
| return "Execute integration tests for Terraform modules" | |
| } | |
| func (c *TestCommand) Run(rawArgs []string) int { | |
| preparation, diags := c.setupTestExecution(moduletest.NormalMode, "test", rawArgs) | |
| if diags.HasErrors() { | |
| return 1 | |
| } | |
| args := preparation.Args | |
| view := preparation.View | |
| config := preparation.Config | |
| variables := preparation.Variables | |
| testVariables := preparation.TestVariables | |
| opts := preparation.Opts | |
| // We have two levels of interrupt here. A 'stop' and a 'cancel'. A 'stop' | |
| // is a soft request to stop. We'll finish the current test, do the tidy up, | |
| // but then skip all remaining tests and run blocks. A 'cancel' is a hard | |
| // request to stop now. We'll cancel the current operation immediately | |
| // even if it's a delete operation, and we won't clean up any infrastructure | |
| // if we're halfway through a test. We'll print details explaining what was | |
| // stopped so the user can do their best to recover from it. | |
| runningCtx, done := context.WithCancel(context.Background()) | |
| stopCtx, stop := context.WithCancel(runningCtx) | |
| cancelCtx, cancel := context.WithCancel(context.Background()) | |
| var runner moduletest.TestSuiteRunner | |
| if len(args.CloudRunSource) > 0 { | |
| var renderer *jsonformat.Renderer | |
| if args.ViewType == arguments.ViewHuman { | |
| // We only set the renderer if we want Human-readable output. | |
| // Otherwise, we just let the runner echo whatever data it receives | |
| // back from the agent anyway. | |
| renderer = &jsonformat.Renderer{ | |
| Streams: c.Streams, | |
| Colorize: c.Colorize(), | |
| RunningInAutomation: c.RunningInAutomation, | |
| } | |
| } | |
| runner = &cloud.TestSuiteRunner{ | |
| ConfigDirectory: ".", // Always loading from the current directory. | |
| TestingDirectory: args.TestDirectory, | |
| Config: config, | |
| Services: c.Services, | |
| Source: args.CloudRunSource, | |
| GlobalVariables: variables, | |
| Stopped: false, | |
| Cancelled: false, | |
| StoppedCtx: stopCtx, | |
| CancelledCtx: cancelCtx, | |
| Verbose: args.Verbose, | |
| OperationParallelism: args.OperationParallelism, | |
| Filters: args.Filter, | |
| Renderer: renderer, | |
| View: view, | |
| Streams: c.Streams, | |
| } | |
| } else { | |
| localRunner := &local.TestSuiteRunner{ | |
| BackendFactory: backendInit.Backend, | |
| Config: config, | |
| // The GlobalVariables are loaded from the | |
| // main configuration directory | |
| // The GlobalTestVariables are loaded from the | |
| // test directory | |
| GlobalVariables: variables, | |
| GlobalTestVariables: testVariables, | |
| TestingDirectory: args.TestDirectory, | |
| Opts: opts, | |
| View: view, | |
| Stopped: false, | |
| Cancelled: false, | |
| StoppedCtx: stopCtx, | |
| CancelledCtx: cancelCtx, | |
| Filter: args.Filter, | |
| Verbose: args.Verbose, | |
| Concurrency: args.RunParallelism, | |
| DeferralAllowed: args.DeferralAllowed, | |
| } | |
| // JUnit output is only compatible with local test execution | |
| if args.JUnitXMLFile != "" { | |
| // Make sure TestCommand's calls loadConfigWithTests before this code, so configLoader is not nil | |
| localRunner.JUnit = junit.NewTestJUnitXMLFile(args.JUnitXMLFile, c.configLoader, localRunner) | |
| } | |
| runner = localRunner | |
| } | |
| var testDiags tfdiags.Diagnostics | |
| var status moduletest.Status | |
| go func() { | |
| defer logging.PanicHandler() | |
| defer done() | |
| defer stop() | |
| defer cancel() | |
| status, testDiags = runner.Test(c.AllowExperimentalFeatures) | |
| }() | |
| // Wait for the operation to complete, or for an interrupt to occur. | |
| select { | |
| case <-c.ShutdownCh: | |
| // Nice request to be cancelled. | |
| view.Interrupted() | |
| runner.Stop() | |
| stop() | |
| select { | |
| case <-c.ShutdownCh: | |
| // The user pressed it again, now we have to get it to stop as | |
| // fast as possible. | |
| view.FatalInterrupt() | |
| runner.Cancel() | |
| cancel() | |
| waitTime := 5 * time.Second | |
| if len(args.CloudRunSource) > 0 { | |
| // We wait longer for cloud runs because the agent should force | |
| // kill the remote job after 5 seconds (as defined above). | |
| // | |
| // This can take longer as the remote agent doesn't receive the | |
| // interrupt immediately. So for cloud runs, we'll wait a minute | |
| // which should give the remote process enough to receive the | |
| // signal, process it, and exit. | |
| // | |
| // If after a minute, the job still hasn't finished then we | |
| // assume something else has gone wrong and we'll just have to | |
| // live with the consequences. | |
| waitTime = time.Minute | |
| } | |
| // We'll wait 5 seconds for this operation to finish now, regardless | |
| // of whether it finishes successfully or not. | |
| select { | |
| case <-runningCtx.Done(): | |
| case <-time.After(waitTime): | |
| } | |
| case <-runningCtx.Done(): | |
| // The application finished nicely after the request was stopped. | |
| } | |
| case <-runningCtx.Done(): | |
| // tests finished normally with no interrupts. | |
| } | |
| view.Diagnostics(nil, nil, testDiags) | |
| if status != moduletest.Pass { | |
| return 1 | |
| } | |
| return 0 | |
| } | |
| type TestRunnerSetup struct { | |
| Args *arguments.Test | |
| View views.Test | |
| Config *configs.Config | |
| Variables map[string]arguments.UnparsedVariableValue | |
| TestVariables map[string]arguments.UnparsedVariableValue | |
| Opts *terraform.ContextOpts | |
| } | |
| func (m *Meta) setupTestExecution(mode moduletest.CommandMode, command string, rawArgs []string) (preparation TestRunnerSetup, diags tfdiags.Diagnostics) { | |
| common, rawArgs := arguments.ParseView(rawArgs) | |
| m.View.Configure(common) | |
| var moreDiags tfdiags.Diagnostics | |
| // Since we build the colorizer for the cloud runner outside the views | |
| // package we need to propagate our no-color setting manually. Once the | |
| // cloud package is fully migrated over to the new streams IO we should be | |
| // able to remove this. | |
| m.color = !common.NoColor | |
| m.Color = m.color | |
| preparation.Args, moreDiags = arguments.ParseTest(rawArgs) | |
| diags = diags.Append(moreDiags) | |
| if moreDiags.HasErrors() { | |
| m.View.Diagnostics(diags) | |
| m.View.HelpPrompt(command) | |
| return | |
| } | |
| if preparation.Args.Repair && mode != moduletest.CleanupMode { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid command mode", | |
| "The -repair flag is only valid for the 'test cleanup' command.")) | |
| m.View.Diagnostics(diags) | |
| return preparation, diags | |
| } | |
| m.parallelism = preparation.Args.OperationParallelism | |
| view := views.NewTest(preparation.Args.ViewType, m.View) | |
| preparation.View = view | |
| // EXPERIMENTAL: maybe enable deferred actions | |
| if !m.AllowExperimentalFeatures && preparation.Args.DeferralAllowed { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to parse command-line flags", | |
| "The -allow-deferral flag is only valid in experimental builds of Terraform.", | |
| )) | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| // The specified testing directory must be a relative path, and it must | |
| // point to a directory that is a descendant of the configuration directory. | |
| if !filepath.IsLocal(preparation.Args.TestDirectory) { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid testing directory", | |
| "The testing directory must be a relative path pointing to a directory local to the configuration directory.")) | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| loader, err := m.initConfigLoader() | |
| if err != nil { | |
| diags = diags.Append(err) | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| registerFileSource := func(filename string, src []byte) { | |
| loader.Parser().ForceFileSource(filename, src) | |
| } | |
| // Collect variables for "terraform test" | |
| preparation.TestVariables, moreDiags = arguments.CollectValuesForTests(preparation.Args.TestDirectory, registerFileSource) | |
| diags = diags.Append(moreDiags) | |
| // Collect variable values and add them to the operation request. | |
| // We must collect these before loading config, because | |
| // loadConfigWithTests needs const variable values available in | |
| // m.VariableValues to resolve dynamic module sources. | |
| var varDiags tfdiags.Diagnostics | |
| preparation.Variables, varDiags = preparation.Args.Vars.CollectValues(registerFileSource) | |
| diags = diags.Append(varDiags) | |
| if diags.HasErrors() { | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| // Only populate m.VariableValues with variables that are declared | |
| // as const in the root module. loadConfigWithTests uses | |
| // m.VariableValues to resolve dynamic module sources via | |
| // ParseConstVariableValues, which would otherwise error on | |
| // undeclared variables passed via -var that are intended for | |
| // test runs rather than the root module. | |
| // | |
| // We do an early load of just the root module to discover which | |
| // variables are const. We discard non-error diagnostics from this | |
| // early load since loadConfigWithTests will re-parse and report them. | |
| earlyMod, earlyDiags := m.loadSingleModuleWithTests(".", preparation.Args.TestDirectory) | |
| if earlyDiags.HasErrors() { | |
| diags = diags.Append(earlyDiags) | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| constVars := make(map[string]arguments.UnparsedVariableValue) | |
| for name, val := range preparation.Variables { | |
| if decl, exists := earlyMod.Variables[name]; exists && decl.Const { | |
| constVars[name] = val | |
| } | |
| } | |
| m.VariableValues = constVars | |
| preparation.Config, moreDiags = m.loadConfigWithTests(".", preparation.Args.TestDirectory) | |
| diags = diags.Append(moreDiags) | |
| if moreDiags.HasErrors() { | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| // Per file, ensure backends: | |
| // * aren't reused | |
| // * are valid types | |
| var backendDiags tfdiags.Diagnostics | |
| for _, tf := range preparation.Config.Module.Tests { | |
| bucketHashes := make(map[int]string) | |
| // Use an ordered list of backends, so that errors are raised by 2nd+ time | |
| // that a backend config is used in a file. | |
| for _, bc := range orderBackendsByDeclarationLine(tf.BackendConfigs) { | |
| f := backendInit.Backend(bc.Backend.Type) | |
| if f == nil { | |
| detail := fmt.Sprintf("There is no backend type named %q.", bc.Backend.Type) | |
| if msg, removed := backendInit.RemovedBackends[bc.Backend.Type]; removed { | |
| detail = msg | |
| } | |
| backendDiags = backendDiags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Unsupported backend type", | |
| Detail: detail, | |
| Subject: &bc.Backend.TypeRange, | |
| }) | |
| continue | |
| } | |
| b := f() | |
| schema := b.ConfigSchema() | |
| hash := bc.Backend.Hash(schema) | |
| if runName, exists := bucketHashes[hash]; exists { | |
| // This backend's been encountered before | |
| backendDiags = backendDiags.Append( | |
| &hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Repeat use of the same backend block", | |
| Detail: fmt.Sprintf("The run %q contains a backend configuration that's already been used in run %q. Sharing the same backend configuration between separate runs will result in conflicting state updates.", bc.Run.Name, runName), | |
| Subject: bc.Backend.TypeRange.Ptr(), | |
| }, | |
| ) | |
| continue | |
| } | |
| bucketHashes[bc.Backend.Hash(schema)] = bc.Run.Name | |
| } | |
| } | |
| diags = diags.Append(backendDiags) | |
| if backendDiags.HasErrors() { | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| opts, err := m.contextOpts() | |
| if err != nil { | |
| diags = diags.Append(err) | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| preparation.Opts = opts | |
| // Print out all the diagnostics we have from the setup. These will just be | |
| // warnings, and we want them out of the way before we start the actual | |
| // testing. | |
| view.Diagnostics(nil, nil, diags) | |
| return | |
| } | |
| // orderBackendsByDeclarationLine takes in a map of state keys to backend configs and returns a list of | |
| // those backend configs, sorted by the line their declaration range starts on. This allows identification | |
| // of the 2nd+ time that a backend configuration is used in the same file. | |
| func orderBackendsByDeclarationLine(backendConfigs map[string]configs.RunBlockBackend) []configs.RunBlockBackend { | |
| bcs := slices.Collect(maps.Values(backendConfigs)) | |
| sort.Slice(bcs, func(i, j int) bool { | |
| return bcs[i].Run.DeclRange.Start.Line < bcs[j].Run.DeclRange.Start.Line | |
| }) | |
| return bcs | |
| } |