Skip to content

Commit 9cc94e0

Browse files
committed
support reading stdout/stderr from streams
It can be useful to let the user run the test command(s) itself, for example when complex shell scripts are involved which need to invoke `go test` multiple times. With -stdin, the `go test` stdout is expected on stdin of gotestsum, so it can be used in a pipe. To detect abnormal termination of the test commands, bash with "set -o pipefail" should be used. Beware that such failures are not detected by gotestsum. To also capture stderr with gotestsum, stderr must get redirected like this: mkfifo /tmp/pipe go test ... 2>/tmp/pipe | gotestsum -stdin -stderr 3 3</tmp/pipe
1 parent 8253d0e commit 9cc94e0

8 files changed

+218
-7
lines changed

README.md

+36
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ source with `go install gotest.tools/gotestsum@latest`. To run without installin
1717
- Print a [summary](#summary) of the test run after running all the tests.
1818
- Use any [`go test` flag](#custom-go-test-command),
1919
run a script with [`--raw-command`](#custom-go-test-command),
20+
run `go test` manually and [pipe the output](#pipe) into `gotestsum`
2021
or [run a compiled test binary](#executing-a-compiled-test-binary).
2122

2223
**CI and Automation**
@@ -306,6 +307,41 @@ gotestsum --raw-command ./profile.sh ./...
306307
TEST_DIRECTORY=./io/http gotestsum
307308
```
308309

310+
### Pipe into gotestsum
311+
312+
When using a shell script which decides how to invoke `go test`, it can be
313+
difficult to generate a script for use with `--raw-command`. A more natural
314+
approach in a shell script is using a pipe:
315+
316+
**Example: simple pipe**
317+
```
318+
go test . | gotestsum --stdin
319+
```
320+
321+
As with `--raw-command` above, only `test2json` output is allowed on
322+
stdin. Anything else causes `gotestsum` to fail with a parser error.
323+
324+
In this simple example, stderr of the test goes to the console and is not
325+
captured by `gotestsum`. To get that behavior, stderr of the first command can
326+
be redirected to a named pipe and then be read from there by `gotestsum`:
327+
328+
**Example: redirect stdout and stderr**
329+
```
330+
mkfifo /tmp/stderr-pipe
331+
332+
go test 2>/tmp/stderr-pipe | gotestsum --stdin --stderr 3 3</tmp/stderr-pipe
333+
```
334+
335+
Note that `gotestsum` is not aware of a non-zero exit code of the test
336+
command. Bash's `pipefile` can be used to detect such a failure:
337+
338+
**Example: pipefail**
339+
```
340+
set -o pipefail # bashism
341+
go test . | gotestsum --stdin
342+
res=$? # captures result of `go test` or `gotestsum`
343+
```
344+
309345
### Executing a compiled test binary
310346

311347
`gotestsum` supports executing a compiled test binary (created with `go test -c`) by running

cmd/main.go

+48-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
@@ -70,6 +71,10 @@ func setupFlags(name string) (*pflag.FlagSet, *options) {
7071
"use different icons, see help for options")
7172
flags.BoolVar(&opts.rawCommand, "raw-command", false,
7273
"don't prepend 'go test -json' to the 'go test' command")
74+
flags.BoolVar(&opts.readStdin, "stdin", false,
75+
"don't run any command, instead read go test stdout from stdin")
76+
flags.IntVar(&opts.readStderrFD, "stderr", 0,
77+
"read go test stderr from a certain `file descriptor` (only valid in combination with -stdin)")
7378
flags.BoolVar(&opts.ignoreNonJSONOutputLines, "ignore-non-json-output-lines", false,
7479
"write non-JSON 'go test' output lines to stderr instead of failing")
7580
flags.Lookup("ignore-non-json-output-lines").Hidden = true
@@ -176,6 +181,8 @@ type options struct {
176181
formatOptions testjson.FormatOptions
177182
debug bool
178183
rawCommand bool
184+
readStdin bool
185+
readStderrFD int
179186
ignoreNonJSONOutputLines bool
180187
jsonFile string
181188
jsonFileTimingEvents string
@@ -198,6 +205,8 @@ type options struct {
198205
version bool
199206

200207
// shims for testing
208+
stdin io.Reader
209+
fd3 io.Reader
201210
stdout io.Writer
202211
stderr io.Writer
203212
}
@@ -212,6 +221,15 @@ func (o options) Validate() error {
212221
return fmt.Errorf("-failfast can not be used with --rerun-fails " +
213222
"because not all test cases will run")
214223
}
224+
if o.rawCommand && o.readStdin {
225+
return errors.New("--stdin and --raw-command are mutually exclusive")
226+
}
227+
if o.readStdin && len(o.args) > 0 {
228+
return fmt.Errorf("--stdin does not support additional arguments (%q)", o.args)
229+
}
230+
if o.readStderrFD > 0 && !o.readStdin {
231+
return errors.New("--stderr depends on --stdin")
232+
}
215233
return nil
216234
}
217235

@@ -264,29 +282,52 @@ func run(opts *options) error {
264282
return err
265283
}
266284

267-
goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{}))
268-
if err != nil {
269-
return err
270-
}
271-
272285
handler, err := newEventHandler(opts)
273286
if err != nil {
274287
return err
275288
}
276289
defer handler.Close() // nolint: errcheck
277290
cfg := testjson.ScanConfig{
278-
Stdout: goTestProc.stdout,
279-
Stderr: goTestProc.stderr,
280291
Handler: handler,
281292
Stop: cancel,
282293
IgnoreNonJSONOutputLines: opts.ignoreNonJSONOutputLines,
283294
}
295+
296+
var goTestProc *proc
297+
if opts.readStdin {
298+
cfg.Stdout = os.Stdin
299+
if opts.stdin != nil {
300+
cfg.Stdout = opts.stdin
301+
}
302+
if opts.readStderrFD > 0 {
303+
if opts.readStderrFD == 3 && opts.fd3 != nil {
304+
cfg.Stderr = opts.fd3
305+
} else {
306+
cfg.Stderr = os.NewFile(uintptr(opts.readStderrFD), fmt.Sprintf("go test stderr on fd %d", opts.stderr))
307+
}
308+
} else {
309+
cfg.Stderr = bytes.NewReader(nil)
310+
}
311+
} else {
312+
p, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{}))
313+
if err != nil {
314+
return err
315+
}
316+
goTestProc = p
317+
cfg.Stdout = p.stdout
318+
cfg.Stderr = p.stderr
319+
}
320+
284321
exec, err := testjson.ScanTestOutput(cfg)
285322
handler.Flush()
286323
if err != nil {
287324
return finishRun(opts, exec, err)
288325
}
289326

327+
if opts.readStdin {
328+
return finishRun(opts, exec, nil)
329+
}
330+
290331
exitErr := goTestProc.cmd.Wait()
291332
if signum := atomic.LoadInt32(&goTestProc.signal); signum != 0 {
292333
return finishRun(opts, exec, exitError{num: signalExitCode + int(signum)})

cmd/main_e2e_test.go

+99
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,102 @@ func TestE2E_IgnoresWarnings(t *testing.T) {
275275
)
276276
golden.Assert(t, out, "e2e/expected/"+t.Name())
277277
}
278+
279+
func TestE2E_StdinNoError(t *testing.T) {
280+
t.Setenv("GITHUB_ACTIONS", "no")
281+
282+
flags, opts := setupFlags("gotestsum")
283+
args := []string{
284+
"--stdin",
285+
"--format=testname",
286+
}
287+
assert.NilError(t, flags.Parse(args))
288+
opts.args = flags.Args()
289+
290+
bufStdout := new(bytes.Buffer)
291+
opts.stdout = bufStdout
292+
bufStderr := new(bytes.Buffer)
293+
opts.stderr = bufStderr
294+
295+
opts.stdin = strings.NewReader(`{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"}
296+
{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"}
297+
{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"pass","Package":"example.com/test","Test":"TestSomething","Elapsed":0}
298+
{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061}
299+
`)
300+
301+
err := run(opts)
302+
assert.NilError(t, err)
303+
out := text.ProcessLines(t, bufStdout,
304+
text.OpRemoveSummaryLineElapsedTime,
305+
text.OpRemoveTestElapsedTime,
306+
filepath.ToSlash, // for windows
307+
)
308+
golden.Assert(t, out, "e2e/expected/"+t.Name())
309+
}
310+
311+
func TestE2E_StdinFailure(t *testing.T) {
312+
t.Setenv("GITHUB_ACTIONS", "no")
313+
314+
flags, opts := setupFlags("gotestsum")
315+
args := []string{
316+
"--stdin",
317+
"--format=testname",
318+
}
319+
assert.NilError(t, flags.Parse(args))
320+
opts.args = flags.Args()
321+
322+
bufStdout := new(bytes.Buffer)
323+
opts.stdout = bufStdout
324+
bufStderr := new(bytes.Buffer)
325+
opts.stderr = bufStderr
326+
327+
opts.stdin = strings.NewReader(`{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"}
328+
{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"}
329+
{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"fail","Package":"example.com/test","Test":"TestSomething","Elapsed":0}
330+
{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061}
331+
`)
332+
333+
err := run(opts)
334+
assert.NilError(t, err)
335+
out := text.ProcessLines(t, bufStdout,
336+
text.OpRemoveSummaryLineElapsedTime,
337+
text.OpRemoveTestElapsedTime,
338+
filepath.ToSlash, // for windows
339+
)
340+
golden.Assert(t, out, "e2e/expected/"+t.Name())
341+
}
342+
343+
func TestE2E_StdinStderr(t *testing.T) {
344+
t.Setenv("GITHUB_ACTIONS", "no")
345+
346+
flags, opts := setupFlags("gotestsum")
347+
args := []string{
348+
"--stdin",
349+
"--stderr=3",
350+
"--format=testname",
351+
}
352+
assert.NilError(t, flags.Parse(args))
353+
opts.args = flags.Args()
354+
355+
bufStdout := new(bytes.Buffer)
356+
opts.stdout = bufStdout
357+
bufStderr := new(bytes.Buffer)
358+
opts.stderr = bufStderr
359+
360+
opts.stdin = strings.NewReader(`{"Time":"2024-06-16T14:46:00.343974039+02:00","Action":"start","Package":"example.com/test"}
361+
{"Time":"2024-06-16T14:46:00.378597503+02:00","Action":"run","Package":"example.com/test","Test":"TestSomething"}
362+
{"Time":"2024-06-16T14:46:00.378798569+02:00","Action":"pass","Package":"example.com/test","Test":"TestSomething","Elapsed":0}
363+
{"Time":"2024-06-16T14:46:00.404809796+02:00","Action":"pass","Package":"example.com/test","Elapsed":0.061}
364+
`)
365+
366+
opts.fd3 = strings.NewReader(`build failure`)
367+
368+
err := run(opts)
369+
assert.NilError(t, err)
370+
out := text.ProcessLines(t, bufStdout,
371+
text.OpRemoveSummaryLineElapsedTime,
372+
text.OpRemoveTestElapsedTime,
373+
filepath.ToSlash, // for windows
374+
)
375+
golden.Assert(t, out, "e2e/expected/"+t.Name())
376+
}

cmd/main_test.go

+15
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ func TestOptions_Validate_FromFlags(t *testing.T) {
8888
args: []string{"--rerun-fails", "--packages=./...", "--", "-failfast"},
8989
expected: "-failfast can not be used with --rerun-fails",
9090
},
91+
{
92+
name: "raw-command and stdin mutually exclusive",
93+
args: []string{"--raw-command", "--stdin"},
94+
expected: "--stdin and --raw-command are mutually exclusive",
95+
},
96+
{
97+
name: "stdin must not be used with args",
98+
args: []string{"--stdin", "--", "-coverprofile=/tmp/out"},
99+
expected: `--stdin does not support additional arguments (["-coverprofile=/tmp/out"])`,
100+
},
101+
{
102+
name: "stderr depends on stdin",
103+
args: []string{"--stderr", "4"},
104+
expected: "--stderr depends on --stdin",
105+
},
91106
}
92107
for _, tc := range testCases {
93108
t.Run(tc.name, func(t *testing.T) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
FAIL example.com/test.TestSomething
2+
PASS example.com/test
3+
4+
=== Failed
5+
=== FAIL: example.com/test TestSomething
6+
7+
DONE 1 tests, 1 failure
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
PASS example.com/test.TestSomething
2+
PASS example.com/test
3+
4+
DONE 1 tests
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
PASS example.com/test.TestSomething
2+
PASS example.com/test
3+
4+
=== Errors
5+
build failure
6+
7+
DONE 1 tests, 1 error

cmd/testdata/gotestsum-help-text

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Flags:
2626
--rerun-fails-max-failures int do not rerun any tests if the initial run has more than this number of failures (default 10)
2727
--rerun-fails-report string write a report to the file, of the tests that were rerun
2828
--rerun-fails-run-root-test rerun the entire root testcase when any of its subtests fail, instead of only the failed subtest
29+
--stderr file descriptor read go test stderr from a certain file descriptor (only valid in combination with -stdin)
30+
--stdin don't run any command, instead read go test stdout from stdin
2931
--version show version and exit
3032
--watch watch go files, and run tests when a file is modified
3133
--watch-chdir in watch mode change the working directory to the directory with the modified file before running tests

0 commit comments

Comments
 (0)