mirror of https://github.com/hashicorp/terraform
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
361 lines
12 KiB
361 lines
12 KiB
// Copyright IBM Corp. 2014, 2026
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
package junit
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"maps"
|
|
"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 JUnit interface, which allows creation of a local
|
|
// file 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
|
|
|
|
// A pointer to the containing test suite runner is needed to monitor details like the command being stopped
|
|
testSuiteRunner moduletest.TestSuiteRunner
|
|
}
|
|
|
|
type JUnit interface {
|
|
Save(*moduletest.Suite) tfdiags.Diagnostics
|
|
}
|
|
|
|
var _ JUnit = (*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, testSuiteRunner moduletest.TestSuiteRunner) *TestJUnitXMLFile {
|
|
return &TestJUnitXMLFile{
|
|
filename: filename,
|
|
configLoader: configLoader,
|
|
testSuiteRunner: testSuiteRunner,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
xmlSrc, err := junitXMLTestReport(suite, v.testSuiteRunner.IsStopped(), 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, suiteRunnerStopped bool, 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})
|
|
|
|
// Sort the file names to ensure consistent ordering in XML
|
|
for _, name := range slices.Sorted(maps.Keys(suite.Files)) {
|
|
file := suite.Files[name]
|
|
// 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)},
|
|
},
|
|
})
|
|
|
|
// Check if there are file-level errors that will be reported at suite level
|
|
hasFileLevelErrors := file.Status == moduletest.Error && file.Diagnostics.HasErrors()
|
|
|
|
for i, 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()
|
|
}
|
|
|
|
// Depending on run status, add either of: "skipped", "failure", or "error" elements
|
|
switch run.Status {
|
|
case moduletest.Skip:
|
|
testCase.Skipped = skipDetails(i, file, suiteRunnerStopped, hasFileLevelErrors)
|
|
|
|
case moduletest.Fail:
|
|
// When the test fails we only use error diags that originate from failing assertions
|
|
var failedAssertions tfdiags.Diagnostics
|
|
for _, d := range run.Diagnostics {
|
|
if tfdiags.DiagnosticCausedByTestFailure(d) {
|
|
failedAssertions = failedAssertions.Append(d)
|
|
}
|
|
}
|
|
|
|
testCase.Failure = &withMessage{
|
|
Message: failureMessage(failedAssertions, len(run.Config.CheckRules)),
|
|
Body: getDiagString(failedAssertions, sources),
|
|
}
|
|
|
|
case moduletest.Error:
|
|
// When the test errors we use all diags with Error severity
|
|
var errDiags tfdiags.Diagnostics
|
|
for _, d := range run.Diagnostics {
|
|
if d.Severity() == tfdiags.Error {
|
|
errDiags = errDiags.Append(d)
|
|
}
|
|
}
|
|
|
|
testCase.Error = &withMessage{
|
|
Message: "Encountered an error",
|
|
Body: getDiagString(errDiags, sources),
|
|
}
|
|
}
|
|
|
|
// Determine if there are diagnostics left unused by the switch block above
|
|
// that should be included in the "system-err" element
|
|
if len(run.Diagnostics) > 0 {
|
|
var systemErrDiags tfdiags.Diagnostics
|
|
|
|
if run.Status == moduletest.Error && run.Diagnostics.HasWarnings() {
|
|
// If the test case errored, then all Error diags are in the "error" element
|
|
// Therefore we'd only need to include warnings in "system-err"
|
|
systemErrDiags = run.Diagnostics.Warnings()
|
|
}
|
|
|
|
if run.Status != moduletest.Error {
|
|
// If a test hasn't errored then we need to find all diagnostics that aren't due
|
|
// to a failing assertion in a test (these are already displayed in the "failure" element)
|
|
|
|
// Collect diags not due to failed assertions, both errors and warnings
|
|
for _, d := range run.Diagnostics {
|
|
if !tfdiags.DiagnosticCausedByTestFailure(d) {
|
|
systemErrDiags = systemErrDiags.Append(d)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(systemErrDiags) > 0 {
|
|
testCase.Stderr = &withMessage{
|
|
Body: getDiagString(systemErrDiags, sources),
|
|
}
|
|
}
|
|
}
|
|
|
|
enc.EncodeElement(&testCase, xml.StartElement{
|
|
Name: caseName,
|
|
})
|
|
}
|
|
|
|
// Add suite-level system-err if there are file-level errors
|
|
if hasFileLevelErrors {
|
|
systemErr := &withMessage{
|
|
Body: getDiagString(file.Diagnostics, sources),
|
|
}
|
|
enc.EncodeElement(systemErr, xml.StartElement{
|
|
Name: xml.Name{Local: "system-err"},
|
|
})
|
|
}
|
|
|
|
enc.EncodeToken(xml.EndElement{Name: suiteName})
|
|
}
|
|
enc.EncodeToken(xml.EndElement{Name: suitesName})
|
|
enc.Close()
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func failureMessage(failedAssertions tfdiags.Diagnostics, checkCount int) string {
|
|
if len(failedAssertions) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if len(failedAssertions) == 1 {
|
|
// Slightly different format if only single assertion failure
|
|
return fmt.Sprintf("%d of %d assertions failed: %s", len(failedAssertions), checkCount, failedAssertions[0].Description().Detail)
|
|
}
|
|
|
|
// Handle multiple assertion failures
|
|
return fmt.Sprintf("%d of %d assertions failed, including: %s", len(failedAssertions), checkCount, failedAssertions[0].Description().Detail)
|
|
}
|
|
|
|
// skipDetails checks data about the test suite, file and runs to determine why a given run was skipped
|
|
// Test can be skipped due to:
|
|
// 1. terraform test recieving an interrupt from users; all unstarted tests will be skipped
|
|
// 2. A previous run in a file has failed, causing subsequent run blocks to be skipped
|
|
// 3. File-level errors (e.g., invalid variable references) causing all tests to be skipped
|
|
// The returned value is used to set content in the "skipped" element
|
|
func skipDetails(runIndex int, file *moduletest.File, suiteStopped bool, hasFileLevelErrors bool) *withMessage {
|
|
if suiteStopped {
|
|
// Test suite experienced an interrupt
|
|
// This block only handles graceful Stop interrupts, as Cancel interrupts will prevent a JUnit file being produced at all
|
|
return &withMessage{
|
|
Message: "Testcase skipped due to an interrupt",
|
|
Body: "Terraform received an interrupt and stopped gracefully. This caused all remaining testcases to be skipped",
|
|
}
|
|
}
|
|
|
|
if file.Status == moduletest.Error {
|
|
// Overall test file marked as errored in the context of a skipped test means tests have been skipped after
|
|
// a previous test/run blocks has errored out
|
|
for i := runIndex; i >= 0; i-- {
|
|
if file.Runs[i].Status == moduletest.Error {
|
|
// Skipped due to error in previous run within the file
|
|
return &withMessage{
|
|
Message: "Testcase skipped due to a previous testcase error",
|
|
Body: fmt.Sprintf("Previous testcase %q ended in error, which caused the remaining tests in the file to be skipped", file.Runs[i].Name),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for file-level error diagnostics that caused tests to be skipped
|
|
// Note: Full diagnostic details are included in suite-level <system-err> element
|
|
if hasFileLevelErrors {
|
|
return &withMessage{
|
|
Message: "Testcase skipped due to file-level errors",
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unhandled case: This results in <skipped></skipped> with no attributes or body
|
|
return &withMessage{}
|
|
}
|
|
|
|
func getDiagString(diags tfdiags.Diagnostics, sources map[string][]byte) string {
|
|
var diagsStr strings.Builder
|
|
for _, d := range diags {
|
|
diagsStr.WriteString(format.DiagnosticPlain(d, sources, 80))
|
|
}
|
|
return diagsStr.String()
|
|
}
|