skip to content

How do you capture what a Go function writes to os.Stdout in a test using os.Pipe?

level: seniorimportance: nice to knowfreq 26%

answer

  1. two files joined by the kernel
  2. os.Stdout is a variable you can repoint
  3. someone must read while it writes
  4. the pipe buffer is only tens of kilobytes
  5. closing the write end signals EOF

basics

~20 s

os.Pipe returns a connected read/write *os.File pair. Point the os.Stdout variable at the write end, drain the read end from another goroutine, then close the writer and restore os.Stdout. Without concurrent draining the test deadlocks.

solid answer

~50 s

`r, w, err := os.Pipe()` gives two `*os.File` values joined by a kernel pipe. Because `os.Stdout` is a package variable you can point it at `w`, run the code under test, then close `w` and put the original back. The part that must be right is draining: a pipe holds only tens of kilobytes, so a test that reads only after the function returns will hang forever on any function that prints more than that. Start a goroutine reading `r` before the call and take the bytes from a channel afterwards; closing the write end is what ends that read with `io.EOF`. It works, but it mutates process-global state, is hostile to `t.Parallel`, and misses any code that captured the original `*os.File` earlier. Where you own the code, take an `io.Writer` parameter and pass a `bytes.Buffer` instead.

code

go · 18 lines
go
r, w, err := os.Pipe()
if err != nil {
	t.Fatal(err)
}
old := os.Stdout
os.Stdout = w

done := make(chan []byte, 1)
go func() {
	b, _ := io.ReadAll(r) // drain concurrently or a large print blocks forever
	done <- b
}()

printReport()

w.Close() // gives the reader EOF
os.Stdout = old
got := string(<-done)

go deeper

for a junior

Know that os.Pipe returns a connected reader and writer as *os.File values, and that os.Stdout can be pointed at the write end because it is an ordinary package variable.

for a middle

Explain why the read has to happen concurrently with the write, what the fixed kernel pipe buffer does when it fills, and why closing the write end is what ends the read.

for a senior

Show the design judgment: inject an io.Writer where you own the code, keep the pipe swap for code you cannot change, and be explicit that it forfeits parallel tests and process isolation.

for a principal

The lesson worth institutionalising is that printing straight to a global stream is an untestable dependency. Make the output sink a parameter in the team's code so no test ever needs process-wide surgery.

## What os.Pipe gives you `os.Pipe()` returns `(r *os.File, w *os.File, err error)`: a kernel pipe with real file descriptors, presented as two files. Bytes written to `w` are readable from `r`. Because both ends are genuine `*os.File` values, they can stand in anywhere a file is expected - including as the value of `os.Stdout`. That is different from a purely in-process reader/writer pairing: `os.Pipe` costs a system call and gives you descriptors the kernel knows about, which is exactly what you need when the thing you want to intercept writes to descriptor 1. ## The capture pattern ```go r, w, err := os.Pipe() if err != nil { t.Fatal(err) } old := os.Stdout os.Stdout = w done := make(chan []byte, 1) go func() { b, _ := io.ReadAll(r) done <- b }() printReport() // the code under test w.Close() os.Stdout = old got := string(<-done) ``` Four things are load-bearing: 1. **`os.Stdout` is a variable.** Assigning to it is what makes the interception work; nothing else in the program needs to change. 2. **The reader runs concurrently.** A pipe has a fixed kernel buffer - tens of kilobytes on Linux. Once it is full, `write` blocks until someone reads. If your only read happens after `printReport()` returns, and `printReport` prints more than the buffer holds, it never returns. The test hangs until the package timeout, and the panic dump shows a goroutine blocked in a write, which is a confusing trail to follow if you did not expect it. 3. **Closing `w` ends the read.** `io.ReadAll(r)` returns when the pipe reports end of file, and that happens when the last write end is closed. Forget the `Close` and the reading goroutine blocks forever and the channel receive never completes. 4. **Restore the old value.** Leaving `os.Stdout` pointed at a closed pipe breaks every later test in the package, usually in a way that looks unrelated to the test that caused it. ## Why this is a last resort - **It is process-global.** Two tests doing it at once, or one doing it under `t.Parallel`, race on the same variable. The whole capture must be serial. - **It only catches code that reads `os.Stdout` at call time.** A package that did `var out io.Writer = os.Stdout` at initialisation captured the original file and writes straight past your pipe. - **It is slow and fiddly** compared to the alternative, and the failure mode when you get it wrong is a hang rather than a clear error. ## The design that removes the need A function that takes its destination as a parameter is trivially testable: ```go func printReport(w io.Writer, r Report) error ``` In production the caller passes `os.Stdout` (or a `bufio.Writer` over it); in a test it passes a `bytes.Buffer` and asserts on `buf.String()`. No globals, no pipe, no goroutine, and it runs happily under `t.Parallel`. Threading the writer down from `main` also removes the temptation for library code to decide where output goes - a decision only the program should make. So the honest answer to the interview question is both halves: here is the pipe technique, precisely, for code you cannot change; and here is why the code you own should never need it. ## Other legitimate uses of os.Pipe The same primitive appears wherever something needs a real descriptor rather than an in-memory writer - handing one end to a subprocess, or giving a C library a file to write into. Whenever you use it, the same two rules apply: someone must be reading while someone is writing, and the write end must be closed for the reader to see the end of the stream.

  • What deadlocks a capture that reads the pipe only after the function returns?
    The kernel pipe has a fixed capacity, tens of kilobytes. Once it fills, the write blocks until someone reads. If the only reader runs after the function under test returns, the function never returns and the test hangs until the timeout, with the dump showing a goroutine stuck in a write. Start the drain before the call, or avoid the pipe entirely.
  • Why is accepting an io.Writer better than swapping os.Stdout in a test?
    A function that takes an `io.Writer` is tested by passing a `bytes.Buffer` - no globals, no pipe, no goroutine, and it runs under `t.Parallel`. Swapping `os.Stdout` mutates process-wide state, races with any other test doing the same, and does nothing for code that stored the original `*os.File` at initialisation. Keep the pipe trick for code you cannot change.
  • Why must the write end be closed before the captured bytes can be read?
    A read on the pipe returns end of file only when every write end is closed; until then the reader simply waits for more data. So `io.ReadAll` on the read end never returns while `w` is open, and the test blocks on the channel receive. Closing `w` after the code under test finishes is what terminates the drain cleanly.

saying these in an interview costs you the question

  • Reads the pipe only after the function under test returns
  • Never closes the write end, so the reader never sees EOF
  • Assumes a kernel pipe buffers an unlimited amount
  • Runs stdout-swapping tests under t.Parallel
  • Never restores the original os.Stdout, breaking later tests
  • Reaches for the pipe trick on code that could just take an io.Writer