Redirecting os.Stdin and os.Stdout in Go
When a Go function reads from os.Stdin and writes to os.Stdout directly, replacing those streams with test doubles requires more than just swapping in io.Reader/io.Writer implementations. Since both are typed as *os.File, you need real file handles to substitute. This is where os.Pipe() comes in.
r, w, err := os.Pipe()
This returns a read end and a write end connected by an OS-level pipe, a thin wrapper around the pipe(2) syscall. To fake os.Stdout, you create a pipe, assign its write end to os.Stdout, and later read whatever was written from the read end:
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// ... code that writes to os.Stdout ...
w.Close()
out, _ := io.ReadAll(r)
os.Stdout = oldStdout
Faking os.Stdin works symmetrically, but with the direction reversed. On the surface, this pattern handles most cases cleanly.
The pipe buffer limitation
The problem with this naive approach is that OS pipes have bounded buffers. On Linux since version 2.6.35, the default capacity is 65,536 bytes (before 2.6.11 it was a single page—4KiB on i386). Once a pipe fills, any write to it blocks until data is drained from the read end—unless O_NONBLOCK is set, in which case it fails outright.
Consider writing 75,000 bytes (e.g., 5,000 copies of "hello to stdout") to the fake os.Stdout before reading from the pipe. The program hangs because nothing empties the pipe while the writes are happening. Sending SIGQUIT reveals execution stalled inside fmt.Print.
This may not bite you in short unit tests, but anything that emits enough output will deadlock. The fix is to start a goroutine that continuously reads from the pipe's read end, keeping the buffer from ever filling up.
A complete stdio faker implementation
A robust faker type can encapsulate all the machinery behind a small API:
type FakeStdio struct {
// private fields
}
The constructor sets up two pipes (one for stdin, one for stdout), swaps the globals, and launches a background goroutine that drains the fake stdout pipe as data arrives:
func NewFakeStdio(input string) *FakeStdio {
inR, inW, _ := os.Pipe()
outR, outW, _ := os.Pipe()
oldStdin := os.Stdin
oldStdout := os.Stdout
os.Stdin = inR
os.Stdout = outW
fs := &FakeStdio{
oldStdin: oldStdin,
oldStdout: oldStdout,
outR: outR,
inW: inW,
done: make(chan struct{}),
}
go func() {
defer fs.outR.Close()
io.Copy(ioutil.Discard, fs.outR)
close(fs.done)
}()
go func() {
defer inW.Close()
io.WriteString(inW, input)
}()
return fs
}
Two design points stand out:
- The goroutine that drains stdout runs until the faker is restored, so arbitrarily large writes to
os.Stdoutnever stall. - The stdin side simply writes the provided input string to the pipe's write end in its own goroutine.
After the code under test finishes, you restore the originals and collect captured output:
func (fs *FakeStdio) ReadAndRestore() (string, error) {
fs.w.Close() // close the fake stdout writer, so the draining goroutine exits
<-fs.done // wait for the goroutine to finish copying
out, err := io.ReadAll(fs.outR)
os.Stdin = fs.oldStdin
os.Stdout = fs.oldStdout
return string(out), err
}
Variations and extensions
A single constructor input isn't sufficient for every scenario. Testing code that reads until stdin closes needs a way to signal EOF, and interactive programs often need incremental input and output:
- Closing stdin. Add a method (e.g.,
CloseStdin) that closes the fake stdin's read end (via the original writer) so thatio.ReadAllor a read loop notices the end of input. - Feeding more stdin. Keep the stdin write end accessible and offer a method to write additional lines later. This is easy to add given the current structure.
- Reading stdout before restore. This requires splitting the single
io.Copyloop into individualReadcalls and adding synchronized access to the capture buffer. - Large stdin. Writing more than 64KiB to the fake stdin pipe will block without another reader; you'd need a goroutine on the user side as well.
- Capturing stderr. The same pattern applies to
os.Stderr.
A note on cgo output
All of the above only intercepts writes made by Go code itself. If your Go program calls C code that writes directly to file descriptor 1 (stdout), replacing the os.Stdout global has no effect—the C runtime never touches it. For that case you need to redirect at the file-descriptor level using the dup and dup2 syscalls, accessible from the syscall package. The approach mirrors the one used for Python's C extensions: duplicate the target descriptor, point fd 1 at your capture pipe, and restore afterwards.



