Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Testing Framework] Add test file HCL configuration and parser functionality #33325

Merged
merged 4 commits into from
Jun 22, 2023
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
11 changes: 10 additions & 1 deletion internal/configs/configload/loader_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions internal/configs/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
//
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/configs/parser_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
115 changes: 108 additions & 7 deletions internal/configs/parser_config_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to understand what this function is doing now. While I think the previous function signature was basically self-explanatory, a function called dirFiles which takes two arguments isn't quite as intuitive. Perhaps a comment would help.

As a starting point, here's my understanding, which might of course be wrong:

dirFiles finds Terraform configuration file paths which are direct descendants of dir, splitting them into primary files and overrides based on filename. If testsDir is non-empty and a sub-directory of that name exists, dirFiles also finds test file paths in that sub-directory and returns them separately.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment!

includeTests := len(testsDir) > 0

infos, err := p.fs.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Expand All @@ -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
}

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

if ext == ".tftest" || ext == ".tftest.json" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we handle .tftest.json here, but not in fileExt below?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug, fixed and added a test that loads .tftest.json files to verify the fix.

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")

Expand All @@ -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 ""
}
Expand All @@ -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
}
Expand Down
31 changes: 31 additions & 0 deletions internal/configs/parser_config_dir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
})
}

Expand Down Expand Up @@ -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.
Expand Down
Loading