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

Write all output to junitxml system-out tag #383

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
58 changes: 31 additions & 27 deletions cmd/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type eventHandler struct {
err *bufio.Writer
jsonFile writeSyncer
jsonFileTimingEvents writeSyncer
junitXMLEncoder *junitxml.Encoder
maxFails int
}

Expand All @@ -36,6 +37,11 @@ func (h *eventHandler) Err(text string) error {
}

func (h *eventHandler) Event(event testjson.TestEvent, execution *testjson.Execution) error {
err := h.formatter.Format(event, execution)
if err != nil {
return fmt.Errorf("failed to format event: %w", err)
}

if err := writeWithNewline(h.jsonFile, event.Bytes()); err != nil {
return fmt.Errorf("failed to write JSON file: %w", err)
}
Expand All @@ -44,10 +50,10 @@ func (h *eventHandler) Event(event testjson.TestEvent, execution *testjson.Execu
return fmt.Errorf("failed to write JSON file: %w", err)
}
}

err := h.formatter.Format(event, execution)
if err != nil {
return fmt.Errorf("failed to format event: %w", err)
if h.junitXMLEncoder != nil {
if err := h.junitXMLEncoder.Handle(event, execution); err != nil {
return fmt.Errorf("failed to write Junit file: %w", err)
}
}

if h.maxFails > 0 && len(execution.Failed()) >= h.maxFails {
Expand Down Expand Up @@ -84,14 +90,19 @@ func (h *eventHandler) Flush() {
func (h *eventHandler) Close() error {
if h.jsonFile != nil {
if err := h.jsonFile.Close(); err != nil {
log.Errorf("Failed to close JSON file: %v", err)
log.Errorf("Failed to write JSON file: %v", err)
}
}
if h.jsonFileTimingEvents != nil {
if err := h.jsonFileTimingEvents.Close(); err != nil {
log.Errorf("Failed to close JSON file: %v", err)
}
}
if h.junitXMLEncoder != nil {
if err := h.junitXMLEncoder.Close(); err != nil {
log.Errorf("Failed to close Junit file: %v", err)
}
}
return nil
}

Expand Down Expand Up @@ -130,30 +141,23 @@ func newEventHandler(opts *options) (*eventHandler, error) {
return handler, fmt.Errorf("failed to create file: %w", err)
}
}
return handler, nil
}
if opts.junitFile != "" {
_ = os.MkdirAll(filepath.Dir(opts.junitFile), 0o755)
junitFile, err := os.Create(opts.junitFile)
if err != nil {
return handler, fmt.Errorf("failed to open JUnit file: %v", err)
}

func writeJUnitFile(opts *options, execution *testjson.Execution) error {
if opts.junitFile == "" {
return nil
handler.junitXMLEncoder = junitxml.NewEncoder(junitFile, junitxml.Config{
ProjectName: opts.junitProjectName,
FormatTestSuiteName: opts.junitTestSuiteNameFormat.Value(),
FormatTestCaseClassname: opts.junitTestCaseClassnameFormat.Value(),
HideEmptyPackages: opts.junitHideEmptyPackages,
Output: opts.junitOutput,
})
}
_ = os.MkdirAll(filepath.Dir(opts.junitFile), 0o755)
junitFile, err := os.Create(opts.junitFile)
if err != nil {
return fmt.Errorf("failed to open JUnit file: %v", err)
}
defer func() {
if err := junitFile.Close(); err != nil {
log.Errorf("Failed to close JUnit file: %v", err)
}
}()

return junitxml.Write(junitFile, execution, junitxml.Config{
ProjectName: opts.junitProjectName,
FormatTestSuiteName: opts.junitTestSuiteNameFormat.Value(),
FormatTestCaseClassname: opts.junitTestCaseClassnameFormat.Value(),
HideEmptyPackages: opts.junitHideEmptyPackages,
})

return handler, nil
}

func postRunHook(opts *options, execution *testjson.Execution) error {
Expand Down
18 changes: 10 additions & 8 deletions cmd/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ func TestWriteJunitFile_CreatesDirectory(t *testing.T) {
junitFile := filepath.Join(dir.Path(), "new-path", "junit.xml")

opts := &options{
format: "debug",
junitFile: junitFile,
junitTestCaseClassnameFormat: &junitFieldFormatValue{},
junitTestSuiteNameFormat: &junitFieldFormatValue{},
}
exec := &testjson.Execution{}
err := writeJUnitFile(opts, exec)
_, err := newEventHandler(opts)
assert.NilError(t, err)

_, err = os.Stat(junitFile)
Expand All @@ -143,10 +143,15 @@ func TestScanTestOutput_TestTimeoutPanicRace(t *testing.T) {
run := func(t *testing.T, name string) {
format := testjson.NewEventFormatter(io.Discard, "testname", testjson.FormatOptions{})

buf := new(bufferCloser)
source := golden.Get(t, "input/go-test-json-"+name+".out")
encoder := junitxml.NewEncoder(buf, junitxml.Config{})
cfg := testjson.ScanConfig{
Stdout: bytes.NewReader(source),
Handler: &eventHandler{formatter: format},
Stdout: bytes.NewReader(source),
Handler: &eventHandler{
formatter: format,
junitXMLEncoder: encoder,
},
}
exec, err := testjson.ScanTestOutput(cfg)
assert.NilError(t, err)
Expand All @@ -157,10 +162,7 @@ func TestScanTestOutput_TestTimeoutPanicRace(t *testing.T) {
actual := text.ProcessLines(t, out, text.OpRemoveSummaryLineElapsedTime)
golden.Assert(t, actual, "expected/"+name+"-summary")

var buf bytes.Buffer
err = junitxml.Write(&buf, exec, junitxml.Config{})
assert.NilError(t, err)

assert.NilError(t, encoder.Close())
assert.Assert(t, cmp.Contains(buf.String(), "panic: test timed out"))
}

Expand Down
7 changes: 4 additions & 3 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func setupFlags(name string) (*pflag.FlagSet, *options) {
flags.BoolVar(&opts.junitHideEmptyPackages, "junitfile-hide-empty-pkg",
truthyFlag(lookEnvWithDefault("GOTESTSUM_JUNIT_HIDE_EMPTY_PKG", "")),
"omit packages with no tests from the junit.xml file")
flags.StringVar(&opts.junitOutput, "junitfile-output",
lookEnvWithDefault("GOTESTSUM_JUNITFILE_OUTPUT", "failed"),
"output to include in the junit file (all, failed)")

flags.IntVar(&opts.rerunFailsMaxAttempts, "rerun-fails", 0,
"rerun failed tests until they all pass, or attempts exceeds maximum. Defaults to max 2 reruns when enabled")
Expand Down Expand Up @@ -187,6 +190,7 @@ type options struct {
junitTestCaseClassnameFormat *junitFieldFormatValue
junitProjectName string
junitHideEmptyPackages bool
junitOutput string
rerunFailsMaxAttempts int
rerunFailsMaxInitialFailures int
rerunFailsReportFile string
Expand Down Expand Up @@ -318,9 +322,6 @@ func run(opts *options) error {
func finishRun(opts *options, exec *testjson.Execution, exitErr error) error {
testjson.PrintSummary(opts.stdout, exec, opts.hideSummary.value)

if err := writeJUnitFile(opts, exec); err != nil {
return fmt.Errorf("failed to write junit file: %w", err)
}
if err := postRunHook(opts, exec); err != nil {
return fmt.Errorf("post run command failed: %w", err)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/testdata/gotestsum-help-text
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Flags:
--jsonfile-timing-events string write only the pass, skip, and fail TestEvents to the file
--junitfile string write a JUnit XML file
--junitfile-hide-empty-pkg omit packages with no tests from the junit.xml file
--junitfile-output string output to include in the junit file (all, failed) (default "failed")
--junitfile-project-name string name of the project used in the junit.xml file
--junitfile-testcase-classname field-format format the testcase classname field as: full, relative, short (default full)
--junitfile-testsuite-name field-format format the testsuite name field as: full, relative, short (default full)
Expand Down
86 changes: 76 additions & 10 deletions internal/junitxml/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,53 @@ import (
"gotest.tools/gotestsum/testjson"
)

type Encoder struct {
out io.WriteCloser
cfg Config
output map[string]map[testjson.TestName][]string
execution *testjson.Execution
}

func NewEncoder(out io.WriteCloser, cfg Config) *Encoder {
return &Encoder{
out: out,
cfg: configWithDefaults(cfg),
output: make(map[string]map[testjson.TestName][]string),
}
}

func (e *Encoder) Close() error {
if err := e.writeToFile(); err != nil {
return err
}
return e.out.Close()
}

func (e *Encoder) Handle(event testjson.TestEvent, execution *testjson.Execution) error {
e.execution = execution
if event.Action != testjson.ActionOutput {
return nil
}

if testjson.IsFramingLine(event.Output, event.Test) {
return nil
}

if e.cfg.Output != "all" {
return nil
}

pkg, ok := e.output[event.Package]
if !ok {
pkg = make(map[testjson.TestName][]string)
e.output[event.Package] = pkg
}

name := testjson.TestName(event.Test)
pkg[name] = append(pkg[name], event.Output)
return nil
}

// JUnitTestSuites is a collection of JUnit test suites.
type JUnitTestSuites struct {
XMLName xml.Name `xml:"testsuites"`
Expand Down Expand Up @@ -48,6 +95,7 @@ type JUnitTestCase struct {
Time string `xml:"time,attr"`
SkipMessage *JUnitSkipMessage `xml:"skipped,omitempty"`
Failure *JUnitFailure `xml:"failure,omitempty"`
SystemOut string `xml:"system-out,omitempty"`
}

// JUnitSkipMessage contains the reason why a testcase was skipped.
Expand All @@ -74,6 +122,7 @@ type Config struct {
FormatTestSuiteName FormatFunc
FormatTestCaseClassname FormatFunc
HideEmptyPackages bool
Output string
// This is used for tests to have a consistent timestamp
customTimestamp string
customElapsed string
Expand All @@ -82,16 +131,19 @@ type Config struct {
// FormatFunc converts a string from one format into another.
type FormatFunc func(string) string

// Write creates an XML document and writes it to out.
func Write(out io.Writer, exec *testjson.Execution, cfg Config) error {
if err := write(out, generate(exec, cfg)); err != nil {
func (e *Encoder) writeToFile() error {
if e.execution == nil {
return nil
}
if err := write(e.out, e.generate()); err != nil {
return fmt.Errorf("failed to write JUnit XML: %v", err)
}
return nil
}

func generate(exec *testjson.Execution, cfg Config) JUnitTestSuites {
cfg = configWithDefaults(cfg)
func (e *Encoder) generate() JUnitTestSuites {
cfg := e.cfg
exec := e.execution
version := goVersion()
suites := JUnitTestSuites{
Name: cfg.ProjectName,
Expand All @@ -114,7 +166,7 @@ func generate(exec *testjson.Execution, cfg Config) JUnitTestSuites {
Tests: pkg.Total,
Time: formatDurationAsSeconds(pkg.Elapsed()),
Properties: packageProperties(version),
TestCases: packageTestCases(pkg, cfg.FormatTestCaseClassname),
TestCases: packageTestCases(pkg, cfg.FormatTestCaseClassname, e.output[pkgname]),
Failures: len(pkg.Failed),
Timestamp: cfg.customTimestamp,
}
Expand Down Expand Up @@ -169,7 +221,11 @@ func goVersion() string {
return strings.TrimPrefix(strings.TrimSpace(string(out)), "go version ")
}

func packageTestCases(pkg *testjson.Package, formatClassname FormatFunc) []JUnitTestCase {
func packageTestCases(
pkg *testjson.Package,
formatClassname FormatFunc,
output map[testjson.TestName][]string,
) []JUnitTestCase {
cases := []JUnitTestCase{}

if pkg.TestMainFailed() {
Expand All @@ -185,23 +241,33 @@ func packageTestCases(pkg *testjson.Package, formatClassname FormatFunc) []JUnit

for _, tc := range pkg.Failed {
jtc := newJUnitTestCase(tc, formatClassname)

out := strings.Join(output[tc.Test], "")
if out == "" {
out = strings.Join(pkg.OutputLines(tc), "")
}
jtc.Failure = &JUnitFailure{
Message: "Failed",
Contents: strings.Join(pkg.OutputLines(tc), ""),
Contents: out,
}
jtc.SystemOut = out
cases = append(cases, jtc)
}

for _, tc := range pkg.Skipped {
jtc := newJUnitTestCase(tc, formatClassname)
jtc.SkipMessage = &JUnitSkipMessage{
Message: strings.Join(pkg.OutputLines(tc), ""),
out := strings.Join(output[tc.Test], "")
if out == "" {
out = strings.Join(pkg.OutputLines(tc), "")
}
jtc.SkipMessage = &JUnitSkipMessage{Message: out}
jtc.SystemOut = out
cases = append(cases, jtc)
}

for _, tc := range pkg.Passed {
jtc := newJUnitTestCase(tc, formatClassname)
jtc.SystemOut = strings.Join(output[tc.Test], "")
cases = append(cases, jtc)
}
return cases
Expand Down
34 changes: 24 additions & 10 deletions internal/junitxml/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,44 @@ import (
)

func TestWrite(t *testing.T) {
out := new(bytes.Buffer)
exec := createExecution(t)
t.Setenv("GOVERSION", "go7.7.7")

env.Patch(t, "GOVERSION", "go7.7.7")
err := Write(out, exec, Config{
out := new(bufferCloser)
exec := createExecution(t)
cfg := Config{
ProjectName: "test",
customTimestamp: new(time.Time).Format(time.RFC3339),
customElapsed: "2.1",
})
}
enc := NewEncoder(out, cfg)
assert.NilError(t, enc.Handle(testjson.TestEvent{}, exec))

err := enc.Close()
assert.NilError(t, err)
golden.Assert(t, out.String(), "junitxml-report.golden")
}

type bufferCloser struct {
bytes.Buffer
}

func (bufferCloser) Close() error { return nil }

func TestWrite_HideEmptyPackages(t *testing.T) {
out := new(bytes.Buffer)
exec := createExecution(t)
t.Setenv("GOVERSION", "go7.7.7")

env.Patch(t, "GOVERSION", "go7.7.7")
err := Write(out, exec, Config{
out := new(bufferCloser)
exec := createExecution(t)
cfg := Config{
ProjectName: "test",
HideEmptyPackages: true,
customTimestamp: new(time.Time).Format(time.RFC3339),
customElapsed: "2.1",
})
}
enc := NewEncoder(out, cfg)
assert.NilError(t, enc.Handle(testjson.TestEvent{}, exec))

err := enc.Close()
assert.NilError(t, err)
golden.Assert(t, out.String(), "junitxml-report-skip-empty.golden")
}
Expand Down
Loading