Skip to content

Commit

Permalink
[Testing Framework] Add test file HCL configuration and parser functi…
Browse files Browse the repository at this point in the history
…onality (#33325)

* Add test structure to views package for rendering test output

* Add test file HCL configuration and parser functionality

* address comments
liamcervante authored Jun 22, 2023
1 parent cf3a72a commit cad9aa9
Showing 17 changed files with 790 additions and 8 deletions.
11 changes: 10 additions & 1 deletion internal/configs/configload/loader_load.go
Original file line number Diff line number Diff line change
@@ -22,7 +22,16 @@ import (
// LoadConfig performs the basic syntax and uniqueness validations that are
// required to process the individual modules
func (l *Loader) LoadConfig(rootDir string) (*configs.Config, hcl.Diagnostics) {
rootMod, diags := l.parser.LoadConfigDir(rootDir)
return l.loadConfig(l.parser.LoadConfigDir(rootDir))
}

// LoadConfigWithTests matches LoadConfig, except the configs.Config contains
// any relevant .tftest files.
func (l *Loader) LoadConfigWithTests(rootDir string, testDir string) (*configs.Config, hcl.Diagnostics) {
return l.loadConfig(l.parser.LoadConfigDirWithTests(rootDir, testDir))
}

func (l *Loader) loadConfig(rootMod *configs.Module, diags hcl.Diagnostics) (*configs.Config, hcl.Diagnostics) {
if rootMod == nil || diags.HasErrors() {
// Ensure we return any parsed modules here so that required_version
// constraints can be verified even when encountering errors.
13 changes: 13 additions & 0 deletions internal/configs/module.go
Original file line number Diff line number Diff line change
@@ -53,6 +53,8 @@ type Module struct {
Import []*Import

Checks map[string]*Check

Tests map[string]*TestFile
}

// File describes the contents of a single configuration file.
@@ -92,6 +94,16 @@ type File struct {
Checks []*Check
}

// NewModuleWithTests matches NewModule except it will also load in the provided
// test files.
func NewModuleWithTests(primaryFiles, overrideFiles []*File, testFiles map[string]*TestFile) (*Module, hcl.Diagnostics) {
mod, diags := NewModule(primaryFiles, overrideFiles)
if mod != nil {
mod.Tests = testFiles
}
return mod, diags
}

// NewModule takes a list of primary files and a list of override files and
// produces a *Module by combining the files together.
//
@@ -113,6 +125,7 @@ func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) {
DataResources: map[string]*Resource{},
Checks: map[string]*Check{},
ProviderMetas: map[addrs.Provider]*ProviderMeta{},
Tests: map[string]*TestFile{},
}

// Process the required_providers blocks first, to ensure that all
16 changes: 16 additions & 0 deletions internal/configs/parser_config.go
Original file line number Diff line number Diff line change
@@ -32,6 +32,22 @@ func (p *Parser) LoadConfigFileOverride(path string) (*File, hcl.Diagnostics) {
return p.loadConfigFile(path, true)
}

// LoadTestFile reads the file at the given path and parses it as a Terraform
// test file.
//
// It references the same LoadHCLFile as LoadConfigFile, so inherits the same
// syntax selection behaviours.
func (p *Parser) LoadTestFile(path string) (*TestFile, hcl.Diagnostics) {
body, diags := p.LoadHCLFile(path)
if body == nil {
return nil, diags
}

test, testDiags := loadTestFile(body)
diags = append(diags, testDiags...)
return test, diags
}

func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnostics) {
body, diags := p.LoadHCLFile(path)
if body == nil {
115 changes: 108 additions & 7 deletions internal/configs/parser_config_dir.go
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ import (
// .tf files are parsed using the HCL native syntax while .tf.json files are
// parsed using the HCL JSON syntax.
func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) {
primaryPaths, overridePaths, diags := p.dirFiles(path)
primaryPaths, overridePaths, _, diags := p.dirFiles(path, "")
if diags.HasErrors() {
return nil, diags
}
@@ -50,20 +50,51 @@ func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) {
return mod, diags
}

// LoadConfigDirWithTests matches LoadConfigDir, but the return Module also
// contains any relevant .tftest files.
func (p *Parser) LoadConfigDirWithTests(path string, testDirectory string) (*Module, hcl.Diagnostics) {
primaryPaths, overridePaths, testPaths, diags := p.dirFiles(path, testDirectory)
if diags.HasErrors() {
return nil, diags
}

primary, fDiags := p.loadFiles(primaryPaths, false)
diags = append(diags, fDiags...)
override, fDiags := p.loadFiles(overridePaths, true)
diags = append(diags, fDiags...)
tests, fDiags := p.loadTestFiles(path, testPaths)
diags = append(diags, fDiags...)

mod, modDiags := NewModuleWithTests(primary, override, tests)
diags = append(diags, modDiags...)

mod.SourceDir = path

return mod, diags
}

// ConfigDirFiles returns lists of the primary and override files configuration
// files in the given directory.
//
// If the given directory does not exist or cannot be read, error diagnostics
// are returned. If errors are returned, the resulting lists may be incomplete.
func (p Parser) ConfigDirFiles(dir string) (primary, override []string, diags hcl.Diagnostics) {
return p.dirFiles(dir)
primary, override, _, diags = p.dirFiles(dir, "")
return primary, override, diags
}

// ConfigDirFilesWithTests matches ConfigDirFiles except it also returns the
// paths to any test files within the module.
func (p Parser) ConfigDirFilesWithTests(dir string, testDirectory string) (primary, override, tests []string, diags hcl.Diagnostics) {
return p.dirFiles(dir, testDirectory)
}

// IsConfigDir determines whether the given path refers to a directory that
// exists and contains at least one Terraform config file (with a .tf or
// .tf.json extension.)
// .tf.json extension.). Note, we explicitely exclude checking for tests here
// as tests must live alongside actual .tf config files.
func (p *Parser) IsConfigDir(path string) bool {
primaryPaths, overridePaths, _ := p.dirFiles(path)
primaryPaths, overridePaths, _, _ := p.dirFiles(path, "")
return (len(primaryPaths) + len(overridePaths)) > 0
}

@@ -88,7 +119,16 @@ func (p *Parser) loadFiles(paths []string, override bool) ([]*File, hcl.Diagnost
return files, diags
}

func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Diagnostics) {
// dirFiles finds Terraform configuration files within dir, splitting them into
// primary and override files based on the filename.
//
// If testsDir is not empty, dirFiles will also retrieve Terraform testing files
// both directly within dir and within testsDir as a subdirectory of dir. In
// this way, testsDir acts both as a direction to retrieve test files within the
// main direction and as the location for additional test files.
func (p *Parser) dirFiles(dir string, testsDir string) (primary, override, tests []string, diags hcl.Diagnostics) {
includeTests := len(testsDir) > 0

infos, err := p.fs.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
@@ -101,7 +141,31 @@ func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Dia

for _, info := range infos {
if info.IsDir() {
// We only care about files
if includeTests && info.Name() == testsDir {
testsDir := filepath.Join(dir, info.Name())
testInfos, err := p.fs.ReadDir(testsDir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read module test directory",
Detail: fmt.Sprintf("Module test directory %s does not exist or cannot be read.", testsDir),
})
return
}

for _, testInfo := range testInfos {
if testInfo.IsDir() || IsIgnoredFile(testInfo.Name()) {
continue
}

if strings.HasSuffix(testInfo.Name(), ".tftest") || strings.HasSuffix(testInfo.Name(), ".tftest.json") {
tests = append(tests, filepath.Join(testsDir, testInfo.Name()))
}
}
}

// We only care about the tests directory or terraform configuration
// files.
continue
}

@@ -111,6 +175,13 @@ func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Dia
continue
}

if ext == ".tftest" || ext == ".tftest.json" {
if includeTests {
tests = append(tests, filepath.Join(dir, name))
}
continue
}

baseName := name[:len(name)-len(ext)] // strip extension
isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override")

@@ -125,13 +196,43 @@ func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Dia
return
}

func (p *Parser) loadTestFiles(basePath string, paths []string) (map[string]*TestFile, hcl.Diagnostics) {
var diags hcl.Diagnostics

tfs := make(map[string]*TestFile)
for _, path := range paths {
tf, fDiags := p.LoadTestFile(path)
diags = append(diags, fDiags...)
if tf != nil {
// We index test files relative to the module they are testing, so
// the key is the relative path between basePath and path.
relPath, err := filepath.Rel(basePath, path)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Failed to calculate relative path",
Detail: fmt.Sprintf("Terraform could not calculate the relative path for test file %s and it has been skipped: %s", path, err),
})
continue
}
tfs[relPath] = tf
}
}

return tfs, diags
}

// fileExt returns the Terraform configuration extension of the given
// path, or a blank string if it is not a recognized extension.
func fileExt(path string) string {
if strings.HasSuffix(path, ".tf") {
return ".tf"
} else if strings.HasSuffix(path, ".tf.json") {
return ".tf.json"
} else if strings.HasSuffix(path, ".tftest") {
return ".tftest"
} else if strings.HasSuffix(path, ".tftest.json") {
return ".tftest.json"
} else {
return ""
}
@@ -157,7 +258,7 @@ func IsEmptyDir(path string) (bool, error) {
}

p := NewParser(nil)
fs, os, diags := p.dirFiles(path)
fs, os, _, diags := p.dirFiles(path, "")
if diags.HasErrors() {
return false, diags
}
31 changes: 31 additions & 0 deletions internal/configs/parser_config_dir_test.go
Original file line number Diff line number Diff line change
@@ -73,6 +73,12 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {
if mod.SourceDir != path {
t.Errorf("wrong SourceDir value %q; want %s", mod.SourceDir, path)
}

if len(mod.Tests) > 0 {
// We only load tests when requested, and we didn't request this
// time.
t.Errorf("should not have loaded tests, but found %d", len(mod.Tests))
}
})
}

@@ -107,6 +113,31 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {

}

func TestParserLoadConfigDirWithTests(t *testing.T) {
directories := []string{
"testdata/valid-modules/with-tests",
"testdata/valid-modules/with-tests-nested",
"testdata/valid-modules/with-tests-json",
}

for _, directory := range directories {
t.Run(directory, func(t *testing.T) {
parser := NewParser(nil)
mod, diags := parser.LoadConfigDirWithTests(directory, "tests")
if diags.HasErrors() {
t.Errorf("unexpected error diagnostics")
for _, diag := range diags {
t.Logf("- %s", diag)
}
}

if len(mod.Tests) != 2 {
t.Errorf("incorrect number of test files found: %d", len(mod.Tests))
}
})
}
}

// TestParseLoadConfigDirFailure is a simple test that just verifies that
// a number of test configuration directories (in testdata/invalid-modules)
// produce diagnostics when parsed.
335 changes: 335 additions & 0 deletions internal/configs/test_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
package configs

import (
"fmt"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
)

// TestCommand represents the Terraform a given run block will execute, plan
// or apply. Defaults to apply.
type TestCommand rune

// TestMode represents the plan mode that Terraform will use for a given run
// block, normal or refresh-only. Defaults to normal.
type TestMode rune

const (
// ApplyTestCommand causes the run block to execute a Terraform apply
// operation.
ApplyTestCommand TestCommand = 0

// PlanTestCommand causes the run block to execute a Terraform plan
// operation.
PlanTestCommand TestCommand = 'P'

// NormalTestMode causes the run block to execute in plans.NormalMode.
NormalTestMode TestMode = 0

// RefreshOnlyTestMode causes the run block to execute in
// plans.RefreshOnlyMode.
RefreshOnlyTestMode TestMode = 'R'
)

// TestFile represents a single test file within a `terraform test` execution.
//
// 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 {
// 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

// Runs defines the sequential list of run blocks that should be executed in
// order.
Runs []*TestRun

VariablesDeclRange hcl.Range
}

// TestRun represents a single run block within a test file.
//
// Each run block represents a single Terraform command to be executed and a set
// of validations to run after the command.
type TestRun struct {
Name string

// Command is the Terraform command to execute.
//
// One of ['apply', 'plan'].
Command TestCommand

// Options contains the embedded plan options that will affect the given
// Command. These should map to the options documented here:
// - https://developer.hashicorp.com/terraform/cli/commands/plan#planning-options
//
// Note, that the Variables are a top level concept and not embedded within
// the options despite being listed as plan options in the documentation.
Options *TestRunOptions

// Variables defines a set of variable definitions for this command.
//
// Any variables specified locally that clash with the global variables will
// take precedence over the global definition.
Variables map[string]hcl.Expression

// CheckRules defines the list of assertions/validations that should be
// checked by this run block.
CheckRules []*CheckRule

NameDeclRange hcl.Range
VariablesDeclRange hcl.Range
DeclRange hcl.Range
}

// TestRunOptions contains the plan options for a given run block.
type TestRunOptions struct {
// Mode is the planning mode to run in. One of ['normal', 'refresh-only'].
Mode TestMode

// Refresh is analogous to the -refresh=false Terraform plan option.
Refresh bool

// Replace is analogous to the -refresh=ADDRESS Terraform plan option.
Replace []hcl.Traversal

// Target is analogous to the -target=ADDRESS Terraform plan option.
Target []hcl.Traversal

DeclRange hcl.Range
}

func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) {
var diags hcl.Diagnostics

content, contentDiags := body.Content(testFileSchema)
diags = append(diags, contentDiags...)

tf := TestFile{}

for _, block := range content.Blocks {
switch block.Type {
case "run":
run, runDiags := decodeTestRunBlock(block)
diags = append(diags, runDiags...)
if !runDiags.HasErrors() {
tf.Runs = append(tf.Runs, run)
}
case "variables":
if tf.Variables != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Multiple \"variables\" blocks",
Detail: fmt.Sprintf("This test file already has a variables block defined at %s.", tf.VariablesDeclRange),
Subject: block.DefRange.Ptr(),
})
continue
}

tf.Variables = make(map[string]hcl.Expression)
tf.VariablesDeclRange = block.DefRange

vars, varsDiags := block.Body.JustAttributes()
diags = append(diags, varsDiags...)
for _, v := range vars {
tf.Variables[v.Name] = v.Expr
}
}
}

return &tf, diags
}

func decodeTestRunBlock(block *hcl.Block) (*TestRun, hcl.Diagnostics) {
var diags hcl.Diagnostics

content, contentDiags := block.Body.Content(testRunBlockSchema)
diags = append(diags, contentDiags...)

r := TestRun{
Name: block.Labels[0],
NameDeclRange: block.LabelRanges[0],
DeclRange: block.DefRange,
}
for _, block := range content.Blocks {
switch block.Type {
case "assert":
cr, crDiags := decodeCheckRuleBlock(block, false)
diags = append(diags, crDiags...)
if !crDiags.HasErrors() {
r.CheckRules = append(r.CheckRules, cr)
}
case "plan_options":
if r.Options != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Multiple \"plan_options\" blocks",
Detail: fmt.Sprintf("This run block already has a plan_options block defined at %s.", r.Options.DeclRange),
Subject: block.DefRange.Ptr(),
})
continue
}

opts, optsDiags := decodeTestRunOptionsBlock(block)
diags = append(diags, optsDiags...)
if !optsDiags.HasErrors() {
r.Options = opts
}
case "variables":
if r.Variables != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Multiple \"variables\" blocks",
Detail: fmt.Sprintf("This run block already has a variables block defined at %s.", r.VariablesDeclRange),
Subject: block.DefRange.Ptr(),
})
continue
}

r.Variables = make(map[string]hcl.Expression)
r.VariablesDeclRange = block.DefRange

vars, varsDiags := block.Body.JustAttributes()
diags = append(diags, varsDiags...)
for _, v := range vars {
r.Variables[v.Name] = v.Expr
}

}
}

if r.Variables == nil {
// There is no distinction between a nil map of variables or an empty
// map, but we can avoid any potential nil pointer exceptions by just
// creating an empty map.
r.Variables = make(map[string]hcl.Expression)
}

if r.Options == nil {
// Create an options with default values if the user didn't specify
// anything.
r.Options = &TestRunOptions{
Mode: NormalTestMode,
Refresh: true,
}
}

if attr, exists := content.Attributes["command"]; exists {
switch hcl.ExprAsKeyword(attr.Expr) {
case "apply":
r.Command = ApplyTestCommand
case "plan":
r.Command = PlanTestCommand
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid \"command\" keyword",
Detail: "The \"command\" argument requires one of the following keywords without quotes: apply or plan.",
Subject: attr.Expr.Range().Ptr(),
})
}
} else {
r.Command = ApplyTestCommand // Default to apply
}

return &r, diags
}

func decodeTestRunOptionsBlock(block *hcl.Block) (*TestRunOptions, hcl.Diagnostics) {
var diags hcl.Diagnostics

content, contentDiags := block.Body.Content(testRunOptionsBlockSchema)
diags = append(diags, contentDiags...)

opts := TestRunOptions{
DeclRange: block.DefRange,
}

if attr, exists := content.Attributes["mode"]; exists {
switch hcl.ExprAsKeyword(attr.Expr) {
case "refresh-only":
opts.Mode = RefreshOnlyTestMode
case "normal":
opts.Mode = NormalTestMode
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid \"mode\" keyword",
Detail: "The \"mode\" argument requires one of the following keywords without quotes: normal or refresh-only",
Subject: attr.Expr.Range().Ptr(),
})
}
} else {
opts.Mode = NormalTestMode // Default to normal
}

if attr, exists := content.Attributes["refresh"]; exists {
diags = append(diags, gohcl.DecodeExpression(attr.Expr, nil, &opts.Refresh)...)
} else {
// Defaults to true.
opts.Refresh = true
}

if attr, exists := content.Attributes["replace"]; exists {
reps, repsDiags := decodeDependsOn(attr)
diags = append(diags, repsDiags...)
opts.Replace = reps
}

if attr, exists := content.Attributes["target"]; exists {
tars, tarsDiags := decodeDependsOn(attr)
diags = append(diags, tarsDiags...)
opts.Target = tars
}

if !opts.Refresh && opts.Mode == RefreshOnlyTestMode {
// These options are incompatible.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incompatible plan options",
Detail: "The \"refresh\" option cannot be set to false when running a test in \"refresh-only\" mode.",
Subject: content.Attributes["refresh"].Range.Ptr(),
})
}

return &opts, diags
}

var testFileSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "run",
LabelNames: []string{"name"},
},
{
Type: "variables",
},
},
}

var testRunBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "command"},
},
Blocks: []hcl.BlockHeaderSchema{
{
Type: "plan_options",
},
{
Type: "assert",
},
{
Type: "variables",
},
},
}

var testRunOptionsBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "mode"},
{Name: "refresh"},
{Name: "replace"},
{Name: "target"},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"variable": {
"input": {
"type": "string"
}
},
"resource": {
"foo_resource": {
"a": {
"value": "${var.input}"
}
},
"bar_resource": {
"c": {}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"run": {
"test_run_one": {
"variables": {
"input": "test_run_one"
},
"assert": [
{
"condition": "${foo_resource.a.value} == test_run_one",
"error_message": "invalid value"
}
]
},
"test_run_two": {
"plan_options": {
"mode": "refresh-only"
},
"variables": {
"input": "test_run_two"
},
"assert": [
{
"condition": "${foo_resource.a.value} == test_run_one",
"error_message": "invalid value"
}
]
},
"test_run_three": {
"variables": {
"input": "test_run_three"
},
"plan_options": {
"replace": [
"bar_resource.c"
]
},
"assert": [
{
"condition": "${foo_resource.a.value} == test_run_three",
"error_message": "invalid value"
}
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"variables": {
"input": "default"
},
"run": {
"test_run_one": {
"command": "plan",
"plan_options": {
"target": [
"foo_resource.a"
]
},
"assert": [
{
"condition": "${foo_resource.a.value} == default",
"error_message": "invalid value"
}
]
},
"test_run_two": {
"variables": {
"input": "custom"
},
"assert": [
{
"condition": "${foo_resource.a.value} == custom",
"error_message": "invalid value"
}
]
}
}
}
11 changes: 11 additions & 0 deletions internal/configs/testdata/valid-modules/with-tests-nested/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

variable "input" {
type = string
}


resource "foo_resource" "a" {
value = var.input
}

resource "bar_resource" "c" {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
variables {
input = "default"
}

# test_run_one runs a partial plan
run "test_run_one" {
command = plan

plan_options {
target = [
foo_resource.a
]
}

assert {
condition = foo_resource.a.value == "default"
error_message = "invalid value"
}
}

# test_run_two does a complete apply operation
run "test_run_two" {
variables {
input = "custom"
}

assert {
condition = foo_resource.a.value == "custom"
error_message = "invalid value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# test_run_one does a complete apply
run "test_run_one" {
variables {
input = "test_run_one"
}

assert {
condition = foo_resource.a.value == "test_run_one"
error_message = "invalid value"
}
}

# test_run_two does a refresh only apply
run "test_run_two" {
plan_options {
mode = refresh-only
}

variables {
input = "test_run_two"
}

assert {
# value shouldn't change, as we're doing a refresh-only apply.
condition = foo_resource.a.value == "test_run_one"
error_message = "invalid value"
}
}

# test_run_three does an apply with a replace operation
run "test_run_three" {
variables {
input = "test_run_three"
}

plan_options {
replace = [
bar_resource.c
]
}

assert {
condition = foo_resource.a.value == "test_run_three"
error_message = "invalid value"
}
}
11 changes: 11 additions & 0 deletions internal/configs/testdata/valid-modules/with-tests/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

variable "input" {
type = string
}


resource "foo_resource" "a" {
value = var.input
}

resource "bar_resource" "c" {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
variables {
input = "default"
}

# test_run_one runs a partial plan
run "test_run_one" {
command = plan

plan_options {
target = [
foo_resource.a
]
}

assert {
condition = foo_resource.a.value == "default"
error_message = "invalid value"
}
}

# test_run_two does a complete apply operation
run "test_run_two" {
variables {
input = "custom"
}

assert {
condition = foo_resource.a.value == "custom"
error_message = "invalid value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# test_run_one does a complete apply
run "test_run_one" {
variables {
input = "test_run_one"
}

assert {
condition = foo_resource.a.value == "test_run_one"
error_message = "invalid value"
}
}

# test_run_two does a refresh only apply
run "test_run_two" {
plan_options {
mode = refresh-only
}

variables {
input = "test_run_two"
}

assert {
# value shouldn't change, as we're doing a refresh-only apply.
condition = foo_resource.a.value == "test_run_one"
error_message = "invalid value"
}
}

# test_run_three does an apply with a replace operation
run "test_run_three" {
variables {
input = "test_run_three"
}

plan_options {
replace = [
bar_resource.c
]
}

assert {
condition = foo_resource.a.value == "test_run_three"
error_message = "invalid value"
}
}
4 changes: 4 additions & 0 deletions internal/moduletest/file.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package moduletest

import "github.com/hashicorp/terraform/internal/configs"

type File struct {
Config *configs.TestFile

Name string
Status Status

3 changes: 3 additions & 0 deletions internal/moduletest/run.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package moduletest

import (
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/tfdiags"
)

type Run struct {
Config *configs.TestRun

Name string
Status Status

0 comments on commit cad9aa9

Please sign in to comment.