diff --git a/README.md b/README.md index 7ace6550..4928180e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ source with `go install gotest.tools/gotestsum@latest`. To run without installin - Print a [summary](#summary) of the test run after running all the tests. - Use any [`go test` flag](#custom-go-test-command), run a script with [`--raw-command`](#custom-go-test-command), + run `go test` manually and [pipe the output](#pipe) into `gotestsum` or [run a compiled test binary](#executing-a-compiled-test-binary). **CI and Automation** @@ -306,6 +307,41 @@ gotestsum --raw-command ./profile.sh ./... TEST_DIRECTORY=./io/http gotestsum ``` +### Pipe into gotestsum + +When using a shell script which decides how to invoke `go test`, it can be +difficult to generate a script for use with `--raw-command`. A more natural +approach in a shell script is using a pipe: + +**Example: simple pipe** +``` +go test . | gotestsum --stdin +``` + +As with `--raw-command` above, only `test2json` output is allowed on +stdin. Anything else causes `gotestsum` to fail with a parser error. + +In this simple example, stderr of the test goes to the console and is not +captured by `gotestsum`. To get that behavior, stderr of the first command can +be redirected to a named pipe and then be read from there by `gotestsum`: + +**Example: redirect stdout and stderr** +``` +mkfifo /tmp/stderr-pipe + +go test 2>/tmp/stderr-pipe | gotestsum --stdin --stderr 3 3 0 { + return fmt.Errorf("--stdin does not support additional arguments (%q)", o.args) + } + if o.readStderrFD > 0 && !o.readStdin { + return errors.New("--stderr depends on --stdin") + } return nil } @@ -264,29 +282,52 @@ func run(opts *options) error { return err } - goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{})) - if err != nil { - return err - } - handler, err := newEventHandler(opts) if err != nil { return err } defer handler.Close() // nolint: errcheck cfg := testjson.ScanConfig{ - Stdout: goTestProc.stdout, - Stderr: goTestProc.stderr, Handler: handler, Stop: cancel, IgnoreNonJSONOutputLines: opts.ignoreNonJSONOutputLines, } + + var goTestProc *proc + if opts.readStdin { + cfg.Stdout = os.Stdin + if opts.stdin != nil { + cfg.Stdout = opts.stdin + } + if opts.readStderrFD > 0 { + if opts.readStderrFD == 3 && opts.fd3 != nil { + cfg.Stderr = opts.fd3 + } else { + cfg.Stderr = os.NewFile(uintptr(opts.readStderrFD), fmt.Sprintf("go test stderr on fd %d", opts.stderr)) + } + } else { + cfg.Stderr = bytes.NewReader(nil) + } + } else { + p, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{})) + if err != nil { + return err + } + goTestProc = p + cfg.Stdout = p.stdout + cfg.Stderr = p.stderr + } + exec, err := testjson.ScanTestOutput(cfg) handler.Flush() if err != nil { return finishRun(opts, exec, err) } + if opts.readStdin { + return finishRun(opts, exec, nil) + } + exitErr := goTestProc.cmd.Wait() if signum := atomic.LoadInt32(&goTestProc.signal); signum != 0 { return finishRun(opts, exec, exitError{num: signalExitCode + int(signum)}) diff --git a/cmd/main_e2e_test.go b/cmd/main_e2e_test.go index bbdaa1d7..5a53fd2f 100644 --- a/cmd/main_e2e_test.go +++ b/cmd/main_e2e_test.go @@ -275,3 +275,104 @@ func TestE2E_IgnoresWarnings(t *testing.T) { ) golden.Assert(t, out, "e2e/expected/"+t.Name()) } + +func TestE2E_StdinNoError(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "no") + + flags, opts := setupFlags("gotestsum") + args := []string{ + "--stdin", + "--format=testname", + } + assert.NilError(t, flags.Parse(args)) + opts.args = flags.Args() + + bufStdout := new(bytes.Buffer) + opts.stdout = bufStdout + bufStderr := new(bytes.Buffer) + opts.stderr = bufStderr + + in := `{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"} +{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"} +{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"pass","Package":"example.com/test","Test":"TestSomething","Elapsed":0} +{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061} +` + opts.stdin = strings.NewReader(in) + + err := run(opts) + assert.NilError(t, err) + out := text.ProcessLines(t, bufStdout, + text.OpRemoveSummaryLineElapsedTime, + text.OpRemoveTestElapsedTime, + filepath.ToSlash, // for windows + ) + golden.Assert(t, out, "e2e/expected/"+t.Name()) +} + +func TestE2E_StdinFailure(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "no") + + flags, opts := setupFlags("gotestsum") + args := []string{ + "--stdin", + "--format=testname", + } + assert.NilError(t, flags.Parse(args)) + opts.args = flags.Args() + + bufStdout := new(bytes.Buffer) + opts.stdout = bufStdout + bufStderr := new(bytes.Buffer) + opts.stderr = bufStderr + + in := `{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"} +{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"} +{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"fail","Package":"example.com/test","Test":"TestSomething","Elapsed":0} +{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061} +` + opts.stdin = strings.NewReader(in) + + err := run(opts) + assert.NilError(t, err) + out := text.ProcessLines(t, bufStdout, + text.OpRemoveSummaryLineElapsedTime, + text.OpRemoveTestElapsedTime, + filepath.ToSlash, // for windows + ) + golden.Assert(t, out, "e2e/expected/"+t.Name()) +} + +func TestE2E_StdinStderr(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "no") + + flags, opts := setupFlags("gotestsum") + args := []string{ + "--stdin", + "--stderr=3", + "--format=testname", + } + assert.NilError(t, flags.Parse(args)) + opts.args = flags.Args() + + bufStdout := new(bytes.Buffer) + opts.stdout = bufStdout + bufStderr := new(bytes.Buffer) + opts.stderr = bufStderr + + in := `{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"} +{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"} +{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"pass","Package":"example.com/test","Test":"TestSomething","Elapsed":0} +{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061} +` + opts.stdin = strings.NewReader(in) + opts.fd3 = strings.NewReader(`build failure`) + + err := run(opts) + assert.NilError(t, err) + out := text.ProcessLines(t, bufStdout, + text.OpRemoveSummaryLineElapsedTime, + text.OpRemoveTestElapsedTime, + filepath.ToSlash, // for windows + ) + golden.Assert(t, out, "e2e/expected/"+t.Name()) +} diff --git a/cmd/main_test.go b/cmd/main_test.go index b64ef222..a297f500 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -88,6 +88,21 @@ func TestOptions_Validate_FromFlags(t *testing.T) { args: []string{"--rerun-fails", "--packages=./...", "--", "-failfast"}, expected: "-failfast can not be used with --rerun-fails", }, + { + name: "raw-command and stdin mutually exclusive", + args: []string{"--raw-command", "--stdin"}, + expected: "--stdin and --raw-command are mutually exclusive", + }, + { + name: "stdin must not be used with args", + args: []string{"--stdin", "--", "-coverprofile=/tmp/out"}, + expected: `--stdin does not support additional arguments (["-coverprofile=/tmp/out"])`, + }, + { + name: "stderr depends on stdin", + args: []string{"--stderr", "4"}, + expected: "--stderr depends on --stdin", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { diff --git a/cmd/testdata/e2e/expected/TestE2E_StdinFailure b/cmd/testdata/e2e/expected/TestE2E_StdinFailure new file mode 100644 index 00000000..b2df8326 --- /dev/null +++ b/cmd/testdata/e2e/expected/TestE2E_StdinFailure @@ -0,0 +1,7 @@ +FAIL example.com/test.TestSomething +PASS example.com/test + +=== Failed +=== FAIL: example.com/test TestSomething + +DONE 1 tests, 1 failure diff --git a/cmd/testdata/e2e/expected/TestE2E_StdinNoError b/cmd/testdata/e2e/expected/TestE2E_StdinNoError new file mode 100644 index 00000000..9eae79a7 --- /dev/null +++ b/cmd/testdata/e2e/expected/TestE2E_StdinNoError @@ -0,0 +1,4 @@ +PASS example.com/test.TestSomething +PASS example.com/test + +DONE 1 tests diff --git a/cmd/testdata/e2e/expected/TestE2E_StdinStderr b/cmd/testdata/e2e/expected/TestE2E_StdinStderr new file mode 100644 index 00000000..fde1ad9c --- /dev/null +++ b/cmd/testdata/e2e/expected/TestE2E_StdinStderr @@ -0,0 +1,7 @@ +PASS example.com/test.TestSomething +PASS example.com/test + +=== Errors +build failure + +DONE 1 tests, 1 error diff --git a/cmd/testdata/gotestsum-help-text b/cmd/testdata/gotestsum-help-text index e4b2c8fc..db4b72e2 100644 --- a/cmd/testdata/gotestsum-help-text +++ b/cmd/testdata/gotestsum-help-text @@ -26,6 +26,8 @@ Flags: --rerun-fails-max-failures int do not rerun any tests if the initial run has more than this number of failures (default 10) --rerun-fails-report string write a report to the file, of the tests that were rerun --rerun-fails-run-root-test rerun the entire root testcase when any of its subtests fail, instead of only the failed subtest + --stderr file descriptor read go test stderr from a certain file descriptor (only valid in combination with -stdin) + --stdin don't run any command, instead read go test stdout from stdin --version show version and exit --watch watch go files, and run tests when a file is modified --watch-chdir in watch mode change the working directory to the directory with the modified file before running tests