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

Refactor JUnit XML output of terraform test into a new junit package #36304

Merged
merged 14 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 9 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
12 changes: 11 additions & 1 deletion internal/backend/local/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/artifact"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/lang"
Expand Down Expand Up @@ -48,7 +49,8 @@ type TestSuiteRunner struct {

Opts *terraform.ContextOpts

View views.Test
View views.Test
Artifact artifact.Artifact

// Stopped and Cancelled track whether the user requested the testing
// process to be interrupted. Stopped is a nice graceful exit, we'll still
Expand Down Expand Up @@ -171,6 +173,14 @@ func (runner *TestSuiteRunner) Test() (moduletest.Status, tfdiags.Diagnostics) {

runner.View.Conclusion(suite)

if runner.Artifact != nil {
artifactDiags := runner.Artifact.Save(suite)
diags = diags.Append(artifactDiags)
if artifactDiags.HasErrors() {
return moduletest.Error, diags
}
}

return suite.Status, diags
}

Expand Down
263 changes: 263 additions & 0 deletions internal/command/artifact/junit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package artifact
SarahFrench marked this conversation as resolved.
Show resolved Hide resolved

import (
"bytes"
"encoding/xml"
"fmt"
"os"
"slices"
"strconv"
"strings"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/configs/configload"
"github.com/hashicorp/terraform/internal/moduletest"
"github.com/hashicorp/terraform/internal/tfdiags"
)

// TestJUnitXMLFile produces a JUnit XML file at the conclusion of a test
// run, summarizing the outcome of the test in a form that can then be
// interpreted by tools which render JUnit XML result reports.
//
// The de-facto convention for JUnit XML is for it to be emitted as a separate
// file as a complement to human-oriented output, rather than _instead of_
// human-oriented output. To meet that expectation the method [TestJUnitXMLFile.Save]
// should be called at the same time as the test's view reaches its "Conclusion" event.
// If that event isn't reached for any reason then no file should be created at
// all, which JUnit XML-consuming tools tend to expect as an outcome of a
// catastrophically-errored test suite.
//
// TestJUnitXMLFile implements the Artifact interface, which allows creation of local
// files that contains a description of a completed test suite. It is intended only
// for use in conjunction with a View that provides the streaming output of ongoing
// testing events.

type TestJUnitXMLFile struct {
filename string

// A config loader is required to access sources, which are used with diagnostics to create XML content
configLoader *configload.Loader
}

type Artifact interface {
Save(*moduletest.Suite) tfdiags.Diagnostics
}

var _ Artifact = (*TestJUnitXMLFile)(nil)

// NewTestJUnitXML returns a [Test] implementation that will, when asked to
// report "conclusion", write a JUnit XML report to the given filename.
//
// If the file already exists then this view will silently overwrite it at the
// point of being asked to write a conclusion. Otherwise it will create the
// file at that time. If creating or overwriting the file fails, a subsequent
// call to method Err will return information about the problem.
func NewTestJUnitXMLFile(filename string, configLoader *configload.Loader) *TestJUnitXMLFile {
return &TestJUnitXMLFile{
filename: filename,
configLoader: configLoader,
}
}

// Save takes in a test suite, generates JUnit XML summarising the test results,
// and saves the content to the filename specified by user
func (v *TestJUnitXMLFile) Save(suite *moduletest.Suite) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics

// Prepare XML content
sources := v.configLoader.Parser().Sources()
SarahFrench marked this conversation as resolved.
Show resolved Hide resolved
xmlSrc, err := junitXMLTestReport(suite, sources)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "error generating JUnit XML test output",
Detail: err.Error(),
})
return diags
}

// Save XML to the specified path
saveDiags := v.save(xmlSrc)
diags = append(diags, saveDiags...)

return diags

}

func (v *TestJUnitXMLFile) save(xmlSrc []byte) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
err := os.WriteFile(v.filename, xmlSrc, 0660)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("error saving JUnit XML to file %q", v.filename),
Detail: err.Error(),
})
return diags
}

return nil
}

type withMessage struct {
Message string `xml:"message,attr,omitempty"`
Body string `xml:",cdata"`
}

type testCase struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Skipped *withMessage `xml:"skipped,omitempty"`
Failure *withMessage `xml:"failure,omitempty"`
Error *withMessage `xml:"error,omitempty"`
Stderr *withMessage `xml:"system-err,omitempty"`

// RunTime is the time spent executing the run associated
// with this test case, in seconds with the fractional component
// representing partial seconds.
//
// We assume here that it's not practically possible for an
// execution to take literally zero fractional seconds at
// the accuracy we're using here (nanoseconds converted into
// floating point seconds) and so use zero to represent
// "not known", and thus omit that case. (In practice many
// JUnit XML consumers treat the absense of this attribute
// as zero anyway.)
RunTime float64 `xml:"time,attr,omitempty"`
Timestamp string `xml:"timestamp,attr,omitempty"`
}

func junitXMLTestReport(suite *moduletest.Suite, sources map[string][]byte) ([]byte, error) {
var buf bytes.Buffer
enc := xml.NewEncoder(&buf)
enc.EncodeToken(xml.ProcInst{
Target: "xml",
Inst: []byte(`version="1.0" encoding="UTF-8"`),
})
enc.Indent("", " ")

// Some common element/attribute names we'll use repeatedly below.
suitesName := xml.Name{Local: "testsuites"}
suiteName := xml.Name{Local: "testsuite"}
caseName := xml.Name{Local: "testcase"}
nameName := xml.Name{Local: "name"}
testsName := xml.Name{Local: "tests"}
skippedName := xml.Name{Local: "skipped"}
failuresName := xml.Name{Local: "failures"}
errorsName := xml.Name{Local: "errors"}

enc.EncodeToken(xml.StartElement{Name: suitesName})

sortedFiles := suiteFilesAsSortedList(suite.Files) // to ensure consistent ordering in XML
for _, file := range sortedFiles {
// Each test file is modelled as a "test suite".

// First we'll count the number of tests and number of failures/errors
// for the suite-level summary.
totalTests := len(file.Runs)
totalFails := 0
totalErrs := 0
totalSkipped := 0
for _, run := range file.Runs {
switch run.Status {
case moduletest.Skip:
totalSkipped++
case moduletest.Fail:
totalFails++
case moduletest.Error:
totalErrs++
}
}
enc.EncodeToken(xml.StartElement{
Name: suiteName,
Attr: []xml.Attr{
{Name: nameName, Value: file.Name},
{Name: testsName, Value: strconv.Itoa(totalTests)},
{Name: skippedName, Value: strconv.Itoa(totalSkipped)},
{Name: failuresName, Value: strconv.Itoa(totalFails)},
{Name: errorsName, Value: strconv.Itoa(totalErrs)},
},
})

for _, run := range file.Runs {
// Each run is a "test case".

testCase := testCase{
Name: run.Name,

// We treat the test scenario filename as the "class name",
// implying that the run name is the "method name", just
// because that seems to inspire more useful rendering in
// some consumers of JUnit XML that were designed for
// Java-shaped languages.
Classname: file.Name,
}
if execMeta := run.ExecutionMeta; execMeta != nil {
testCase.RunTime = execMeta.Duration.Seconds()
testCase.Timestamp = execMeta.StartTimestamp()
}
switch run.Status {
case moduletest.Skip:
testCase.Skipped = &withMessage{
// FIXME: Is there something useful we could say here about
// why the test was skipped?
}
case moduletest.Fail:
testCase.Failure = &withMessage{
Message: "Test run failed",
// FIXME: What's a useful thing to report in the body
// here? A summary of the statuses from all of the
// checkable objects in the configuration?
}
case moduletest.Error:
var diagsStr strings.Builder
for _, diag := range run.Diagnostics {
diagsStr.WriteString(format.DiagnosticPlain(diag, sources, 80))
}
testCase.Error = &withMessage{
Message: "Encountered an error",
Body: diagsStr.String(),
}
}
if len(run.Diagnostics) != 0 && testCase.Error == nil {
// If we have diagnostics but the outcome wasn't an error
// then we're presumably holding diagnostics that didn't
// cause the test to error, such as warnings. We'll place
// those into the "system-err" element instead, so that
// they'll be reported _somewhere_ at least.
var diagsStr strings.Builder
for _, diag := range run.Diagnostics {
diagsStr.WriteString(format.DiagnosticPlain(diag, sources, 80))
}
testCase.Stderr = &withMessage{
Body: diagsStr.String(),
}
}
enc.EncodeElement(&testCase, xml.StartElement{
Name: caseName,
})
}

enc.EncodeToken(xml.EndElement{Name: suiteName})
}
enc.EncodeToken(xml.EndElement{Name: suitesName})
enc.Close()
return buf.Bytes(), nil
}

func suiteFilesAsSortedList(files map[string]*moduletest.File) []*moduletest.File {
fileNames := make([]string, len(files))
i := 0
for k := range files {
fileNames[i] = k
i++
}
slices.Sort(fileNames)

sortedFiles := make([]*moduletest.File, len(files))
for i, name := range fileNames {
sortedFiles[i] = files[name]
}
return sortedFiles
}
Loading
Loading