Chapter 35: Capturing Stdout/Stderr in Tests¶
Description¶
Some functions print directly to os.Stdout or os.Stderr — CLI tools, HackerRank-style challenge solutions, and code that logs to the console. To test them, swap the global os.Stdout/os.Stderr with an os.Pipe() writer, run the function, restore the globals, and read back what was written.
Two capture implementations exist in the wild. A simple one swaps only os.Stdout, reads the pipe after the function returns, and restores manually. An elaborated one swaps both streams plus the log package output, restores with defer (panic-safe), and reads the pipe concurrently from a goroutine — avoiding the pipe-buffer deadlock when a function writes a lot of output.
Real-world examples:
hackerrank/problem-solving/easy/020-bon-appetit/main_test.goandhackerrank/problem-solving/hard/001-matrix-rotation-algo/main_test.go— the simplecaptureStdOut, used to test solutions that print their answer to stdoutgo-crap/internal/scan/scan_test.go:803— the elaboratedcaptureOutput(adapted from rednafi.com/go/capture_console_output), capturing stdout, stderr, andlogoutput forlogCoverageErrors
Code¶
type Row struct {
Label string
Unit string
Value int
}
func PrintTable(rows []Row) {
if len(rows) == 0 {
fmt.Println("(empty)")
return
}
fmt.Println("LABEL | VALUE | UNIT")
fmt.Println("------------+-------+------")
for _, r := range rows {
fmt.Printf("%-10s | %5d | %s\n", r.Label, r.Value, r.Unit)
}
}
func ReportErrors(errs []error) {
for _, err := range errs {
log.Print(err)
}
}
func WriteDiagnostics(msg string) {
fmt.Fprintln(os.Stderr, msg)
}
PrintTable writes to stdout with fmt.Println/fmt.Printf. ReportErrors logs through the standard log package (stderr by default). WriteDiagnostics writes directly to os.Stderr. None of them return a string — the output is the side effect, so the test must capture the stream.
Test¶
// Simple version — swap os.Stdout, run, restore, read.
func captureStdOut(f func()) string {
var (
orig = os.Stdout
r, w, _ = os.Pipe()
)
os.Stdout = w
f()
os.Stdout = orig
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}
// Elaborated version — capture stdout + stderr + log, defer-restore,
// read concurrently to avoid pipe-buffer deadlock.
func captureOutput(f func()) string {
custReader, custWriter, err := os.Pipe()
if err != nil {
panic(err)
}
origStdout := os.Stdout
origStderr := os.Stderr
origLog := log.Writer()
defer func() {
os.Stdout = origStdout
os.Stderr = origStderr
log.SetOutput(origLog)
}()
os.Stdout, os.Stderr = custWriter, custWriter
log.SetOutput(custWriter)
out := make(chan string, 1)
go func() {
var buff bytes.Buffer
io.Copy(&buff, custReader)
out <- buff.String()
}()
f()
_ = custWriter.Close()
return <-out
}
type checkOutputFn func(*testing.T, string)
var checkOutput = func(fns ...checkOutputFn) []checkOutputFn { return fns }
var checkContains = func(want string) checkOutputFn {
return func(t *testing.T, got string) {
t.Helper()
assert.Containsf(t, got, want, "output should contain %q", want)
}
}
var checkNotContains = func(want string) checkOutputFn {
return func(t *testing.T, got string) {
t.Helper()
assert.NotContainsf(t, got, want, "output should not contain %q", want)
}
}
var checkEqual = func(want string) checkOutputFn {
return func(t *testing.T, got string) {
t.Helper()
assert.Equalf(t, want, got, "output mismatch")
}
}
var checkEmpty = func() checkOutputFn {
return func(t *testing.T, got string) {
t.Helper()
assert.Emptyf(t, got, "output should be empty")
}
}
func TestPrintTable(t *testing.T) {
tests := []struct {
name string
rows []Row
checks []checkOutputFn
}{
{
name: "empty_rows",
rows: nil,
checks: checkOutput(
checkEqual("(empty)\n"),
checkNotContains("LABEL"),
),
},
{
name: "single_row",
rows: []Row{{Label: "CPU", Value: 45, Unit: "%"}},
checks: checkOutput(
checkContains("CPU"),
checkContains("45"),
checkContains("%"),
checkContains("LABEL"),
checkNotContains("MEM"),
),
},
{
name: "multiple_rows",
rows: []Row{
{Label: "CPU", Value: 45, Unit: "%"},
{Label: "MEM", Value: 2048, Unit: "MB"},
},
checks: checkOutput(
checkContains("CPU"),
checkContains("MEM"),
checkContains("45"),
checkContains("2048"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := captureStdOut(func() {
PrintTable(tt.rows)
})
for _, c := range tt.checks {
c(t, got)
}
})
}
}
func TestReportErrors(t *testing.T) {
tests := []struct {
name string
errs []error
checks []checkOutputFn
}{
{
name: "no_errors_no_output",
errs: nil,
checks: checkOutput(
checkEmpty(),
),
},
{
name: "single_error_logged",
errs: []error{errors.New("boom")},
checks: checkOutput(
checkContains("boom"),
),
},
{
name: "multiple_errors_logged",
errs: []error{errors.New("err1"), errors.New("err2")},
checks: checkOutput(
checkContains("err1"),
checkContains("err2"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := captureOutput(func() {
ReportErrors(tt.errs)
})
for _, c := range tt.checks {
c(t, got)
}
})
}
}
func TestWriteDiagnostics(t *testing.T) {
tests := []struct {
name string
msg string
checks []checkOutputFn
}{
{
name: "writes_to_stderr",
msg: "disk full",
checks: checkOutput(
checkContains("disk full"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := captureOutput(func() {
WriteDiagnostics(tt.msg)
})
for _, c := range tt.checks {
c(t, got)
}
})
}
}
Scaffold¶
go-testgen report . --format table
go-testgen gen . PrintTable
go-testgen gen . ReportErrors
go-testgen gen . WriteDiagnostics
Testing Approach¶
Stream capture testing:
-
Swap, run, restore, read — the simple
captureStdOutreplaces theos.Stdoutglobal with the write end of anos.Pipe(), invokes the function, restores the global, closes the writer, and reads the pipe. This is the classic HackerRank testing setup, where challenge functions print their answer rather than returning it. -
Elaborated version: deadlock-proof concurrent read —
os.Pipehas a kernel buffer (64KB on Linux). If a function writes more than the buffer holds and nothing reads until the writer closes, the write blocks forever.captureOutputstarts a goroutine that drains the pipe into abytes.Bufferviaio.Copywhile the function runs, then delivers the result on a channel. This is safe for any output size. The go-crap source additionally synchronizes goroutine startup with async.WaitGroup; the channel receive at the end is the only synchronization that really matters. -
Capture stderr and
logoutput too —captureOutputredirectsos.Stdout,os.Stderr, andlog.SetOutputall to the same pipe. This catches code that prints errors to stderr (WriteDiagnostics) and code that logs through the standard logger (ReportErrors). Note that both streams share one pipe here, so the captured string mixes them; separate pipes per stream would let you assert on each independently. -
deferrestores are panic-safe — the elaborated version restoresos.Stdout,os.Stderr, and thelogwriter in adefer, so a panic insidef()cannot leak a redirected global (and, in the go-crap original, an unrestoredlogwriter pointed at a closed pipe). The simple version restores onlyos.Stdout, manually, afterf()returns. -
Not parallel-safe —
os.Stdoutis a package global. Swapping it while another test runs concurrently (viat.Parallel()) would make tests race and corrupt each other's output. Capture tests must run sequentially, unlike thet.Parallel()patterns in chapter 26. -
Capture vs returning strings — this pattern is only necessary when code prints directly to the streams (legacy code, CLI tools, challenge solutions you cannot change). When you own the API, prefer returning a string and asserting on it directly, as in chapter 09 (
assert.Containson the returned value). When you must capture,Contains/NotContainschecks keep assertions resilient to formatting changes, reusing the go-crap check factories from chapters 6–7.
View source code on GitHub