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.Stdout never 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 that io.ReadAll or 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.Copy loop into individual Read calls 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.