gotest.tools/gotestsum@v1.11.0/internal/junitxml/report.go (about)

     1  /*Package junitxml creates a JUnit XML report from a testjson.Execution.
     2   */
     3  package junitxml
     4  
     5  import (
     6  	"bytes"
     7  	"encoding/xml"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"os/exec"
    12  	"strings"
    13  	"time"
    14  
    15  	"gotest.tools/gotestsum/internal/log"
    16  	"gotest.tools/gotestsum/testjson"
    17  )
    18  
    19  // JUnitTestSuites is a collection of JUnit test suites.
    20  type JUnitTestSuites struct {
    21  	XMLName  xml.Name `xml:"testsuites"`
    22  	Name     string   `xml:"name,attr,omitempty"`
    23  	Tests    int      `xml:"tests,attr"`
    24  	Failures int      `xml:"failures,attr"`
    25  	Errors   int      `xml:"errors,attr"`
    26  	Time     string   `xml:"time,attr"`
    27  	Suites   []JUnitTestSuite
    28  }
    29  
    30  // JUnitTestSuite is a single JUnit test suite which may contain many
    31  // testcases.
    32  type JUnitTestSuite struct {
    33  	XMLName    xml.Name        `xml:"testsuite"`
    34  	Tests      int             `xml:"tests,attr"`
    35  	Failures   int             `xml:"failures,attr"`
    36  	Time       string          `xml:"time,attr"`
    37  	Name       string          `xml:"name,attr"`
    38  	Properties []JUnitProperty `xml:"properties>property,omitempty"`
    39  	TestCases  []JUnitTestCase
    40  	Timestamp  string `xml:"timestamp,attr"`
    41  }
    42  
    43  // JUnitTestCase is a single test case with its result.
    44  type JUnitTestCase struct {
    45  	XMLName     xml.Name          `xml:"testcase"`
    46  	Classname   string            `xml:"classname,attr"`
    47  	Name        string            `xml:"name,attr"`
    48  	Time        string            `xml:"time,attr"`
    49  	SkipMessage *JUnitSkipMessage `xml:"skipped,omitempty"`
    50  	Failure     *JUnitFailure     `xml:"failure,omitempty"`
    51  }
    52  
    53  // JUnitSkipMessage contains the reason why a testcase was skipped.
    54  type JUnitSkipMessage struct {
    55  	Message string `xml:"message,attr"`
    56  }
    57  
    58  // JUnitProperty represents a key/value pair used to define properties.
    59  type JUnitProperty struct {
    60  	Name  string `xml:"name,attr"`
    61  	Value string `xml:"value,attr"`
    62  }
    63  
    64  // JUnitFailure contains data related to a failed test.
    65  type JUnitFailure struct {
    66  	Message  string `xml:"message,attr"`
    67  	Type     string `xml:"type,attr"`
    68  	Contents string `xml:",chardata"`
    69  }
    70  
    71  // Config used to write a junit XML document.
    72  type Config struct {
    73  	ProjectName             string
    74  	FormatTestSuiteName     FormatFunc
    75  	FormatTestCaseClassname FormatFunc
    76  	HideEmptyPackages       bool
    77  	// This is used for tests to have a consistent timestamp
    78  	customTimestamp string
    79  	customElapsed   string
    80  }
    81  
    82  // FormatFunc converts a string from one format into another.
    83  type FormatFunc func(string) string
    84  
    85  // Write creates an XML document and writes it to out.
    86  func Write(out io.Writer, exec *testjson.Execution, cfg Config) error {
    87  	if err := write(out, generate(exec, cfg)); err != nil {
    88  		return fmt.Errorf("failed to write JUnit XML: %v", err)
    89  	}
    90  	return nil
    91  }
    92  
    93  func generate(exec *testjson.Execution, cfg Config) JUnitTestSuites {
    94  	cfg = configWithDefaults(cfg)
    95  	version := goVersion()
    96  	suites := JUnitTestSuites{
    97  		Name:     cfg.ProjectName,
    98  		Tests:    exec.Total(),
    99  		Failures: len(exec.Failed()),
   100  		Errors:   len(exec.Errors()),
   101  		Time:     formatDurationAsSeconds(time.Since(exec.Started())),
   102  	}
   103  
   104  	if cfg.customElapsed != "" {
   105  		suites.Time = cfg.customElapsed
   106  	}
   107  	for _, pkgname := range exec.Packages() {
   108  		pkg := exec.Package(pkgname)
   109  		if cfg.HideEmptyPackages && pkg.IsEmpty() {
   110  			continue
   111  		}
   112  		junitpkg := JUnitTestSuite{
   113  			Name:       cfg.FormatTestSuiteName(pkgname),
   114  			Tests:      pkg.Total,
   115  			Time:       formatDurationAsSeconds(pkg.Elapsed()),
   116  			Properties: packageProperties(version),
   117  			TestCases:  packageTestCases(pkg, cfg.FormatTestCaseClassname),
   118  			Failures:   len(pkg.Failed),
   119  			Timestamp:  cfg.customTimestamp,
   120  		}
   121  		if cfg.customTimestamp == "" {
   122  			junitpkg.Timestamp = exec.Started().Format(time.RFC3339)
   123  		}
   124  		suites.Suites = append(suites.Suites, junitpkg)
   125  	}
   126  	return suites
   127  }
   128  
   129  func configWithDefaults(cfg Config) Config {
   130  	noop := func(v string) string {
   131  		return v
   132  	}
   133  	if cfg.FormatTestSuiteName == nil {
   134  		cfg.FormatTestSuiteName = noop
   135  	}
   136  	if cfg.FormatTestCaseClassname == nil {
   137  		cfg.FormatTestCaseClassname = noop
   138  	}
   139  	return cfg
   140  }
   141  
   142  func formatDurationAsSeconds(d time.Duration) string {
   143  	return fmt.Sprintf("%f", d.Seconds())
   144  }
   145  
   146  func packageProperties(goVersion string) []JUnitProperty {
   147  	return []JUnitProperty{
   148  		{Name: "go.version", Value: goVersion},
   149  	}
   150  }
   151  
   152  // goVersion returns the version as reported by the go binary in PATH. This
   153  // version will not be the same as runtime.Version, which is always the version
   154  // of go used to build the gotestsum binary.
   155  //
   156  // To skip the os/exec call set the GOVERSION environment variable to the
   157  // desired value.
   158  func goVersion() string {
   159  	if version, ok := os.LookupEnv("GOVERSION"); ok {
   160  		return version
   161  	}
   162  	log.Debugf("exec: go version")
   163  	cmd := exec.Command("go", "version")
   164  	out, err := cmd.Output()
   165  	if err != nil {
   166  		log.Warnf("Failed to lookup go version for junit xml: %v", err)
   167  		return "unknown"
   168  	}
   169  	return strings.TrimPrefix(strings.TrimSpace(string(out)), "go version ")
   170  }
   171  
   172  func packageTestCases(pkg *testjson.Package, formatClassname FormatFunc) []JUnitTestCase {
   173  	cases := []JUnitTestCase{}
   174  
   175  	if pkg.TestMainFailed() {
   176  		var buf bytes.Buffer
   177  		pkg.WriteOutputTo(&buf, 0) //nolint:errcheck
   178  		jtc := newJUnitTestCase(testjson.TestCase{Test: "TestMain"}, formatClassname)
   179  		jtc.Failure = &JUnitFailure{
   180  			Message:  "Failed",
   181  			Contents: buf.String(),
   182  		}
   183  		cases = append(cases, jtc)
   184  	}
   185  
   186  	for _, tc := range pkg.Failed {
   187  		jtc := newJUnitTestCase(tc, formatClassname)
   188  		jtc.Failure = &JUnitFailure{
   189  			Message:  "Failed",
   190  			Contents: strings.Join(pkg.OutputLines(tc), ""),
   191  		}
   192  		cases = append(cases, jtc)
   193  	}
   194  
   195  	for _, tc := range pkg.Skipped {
   196  		jtc := newJUnitTestCase(tc, formatClassname)
   197  		jtc.SkipMessage = &JUnitSkipMessage{
   198  			Message: strings.Join(pkg.OutputLines(tc), ""),
   199  		}
   200  		cases = append(cases, jtc)
   201  	}
   202  
   203  	for _, tc := range pkg.Passed {
   204  		jtc := newJUnitTestCase(tc, formatClassname)
   205  		cases = append(cases, jtc)
   206  	}
   207  	return cases
   208  }
   209  
   210  func newJUnitTestCase(tc testjson.TestCase, formatClassname FormatFunc) JUnitTestCase {
   211  	return JUnitTestCase{
   212  		Classname: formatClassname(tc.Package),
   213  		Name:      tc.Test.Name(),
   214  		Time:      formatDurationAsSeconds(tc.Elapsed),
   215  	}
   216  }
   217  
   218  func write(out io.Writer, suites JUnitTestSuites) error {
   219  	doc, err := xml.MarshalIndent(suites, "", "\t")
   220  	if err != nil {
   221  		return err
   222  	}
   223  	_, err = out.Write([]byte(xml.Header))
   224  	if err != nil {
   225  		return err
   226  	}
   227  	_, err = out.Write(doc)
   228  	return err
   229  }