skip to content

In Ruby, how do you capture what a command-line tool prints to $stdout and $stderr inside a test, and what output escapes that capture?

level: middleimportance: must knowfreq 50%

answer

  1. swap globals, restore in ensure
  2. StringIO#string holds the text
  3. references cached before the swap
  4. child processes write to descriptor 1
  5. inject out: and err: instead

basics

~20 s

Swap $stdout and $stderr for StringIO objects, run the tool, read each buffer's string, and restore the originals in ensure. Child processes, writes to STDOUT or STDERR, and objects that cached the old stream escape the swap.

solid answer

~40 s

Save `$stdout` and `$stderr`, assign two `StringIO.new` buffers, call the tool, read `buffer.string`, and restore the saved streams in an `ensure` clause so a failing assertion cannot leave the process writing into a buffer. The swap works because `Kernel#puts`, `print` and `warn` look the globals up on every call. It misses anything that does not go through the globals at call time: a child process started with `system` or backticks, which inherits file descriptor 1; code that writes to the `STDOUT`/`STDERR` constants; and any object that stored `$stdout` in a constant or instance variable before the swap. Because the globals are shared, it also races with other threads. The sturdier design injects the streams, `initialize(out: $stdout, err: $stderr)`, so the test passes its own `StringIO` without touching globals.

code

ruby · 14 lines
ruby
require "stringio"

def capture_output
  original_out, original_err = $stdout, $stderr
  $stdout, $stderr = StringIO.new, StringIO.new
  yield
  [$stdout.string, $stderr.string]
ensure
  $stdout, $stderr = original_out, original_err
end

out, err = capture_output { Tally::CLI.new.run([]) }
err # => "usage: tally FILE...\n"
out # =>

go deeper

for a junior

Recall the steps: save the globals, assign StringIO buffers, run the code, read string, restore in ensure.

for a middle

Explain why the swap works (call-time lookup of the globals) and list what escapes it: constants, cached references and child processes.

for a senior

Show the thread-safety and nesting problems of a global swap and argue for injected streams, reserving descriptor-level capture for child processes.

for a principal

Set a convention for CLI and service code to take their streams as arguments, so the whole suite can run in parallel without global capture helpers.

## The scenario You are testing a small command-line tool, say a `Tally::CLI` class whose `run(argv)` counts words in the files it is given and prints a report, with usage errors on standard error. The test needs the exact text the tool printed and must leave the process's real output untouched afterwards. ## The swap-and-restore pattern 1. **Save** the current streams in locals: `original_out, original_err = $stdout, $stderr`. 2. **Swap in buffers**: assign `StringIO.new` to each global. A `StringIO` is an in-memory stream whose `write` appends to a `String`, and the `$stdout` setter accepts it because it responds to `write`. 3. **Run** the code under test. 4. **Read** the text with `StringIO#string`, which returns the whole buffer regardless of the stream's position. 5. **Restore** the saved streams inside `ensure`. Without that, a failing assertion or an exception from the tool leaves every later line of the test run going into a buffer nobody reads. This works because `Kernel#puts` and `print` resolve `$stdout`, and `Kernel#warn` resolves `$stderr`, **at the moment of each call**. Minitest's `capture_io` is this exact pattern wrapped in a helper, and RSpec's output matcher does the same work; those helpers belong to their own frameworks, but they rely on the mechanism described here. ## What escapes the capture | Output source | Captured by the global swap? | Why | |---|---|---| | `puts`, `print`, `warn` in Ruby code | yes | looked up from the globals on every call | | `$stdout.write`, `$stderr.puts` | yes | same global lookup | | `STDOUT.puts`, `STDERR.write` | no | the constants still hold the original IO objects | | an object that saved `$stdout` earlier | no | it holds a reference to the old object, not to the variable | | a child from `system`, backticks or `spawn` | no | it writes to file descriptor 1, which the swap never touched | | C code writing to the descriptor directly | no | same reason | | another thread printing during the test | yes, wrongly | the global is shared, so its output lands in your buffer | The cached-reference row surprises people most. If a class does `OUT = $stdout` when its file loads, or builds a logger object around `$stdout` at boot, that object points at the **original IO**; reassigning the global later changes what the *name* means, not what the logger holds. Capturing a child process's output needs the descriptor itself redirected (with `IO#reopen` to a temporary file, or by collecting the child's output directly), which is the job of the process-handling APIs, not of a variable swap. ## Injecting the streams instead The swap is a workaround for code that hard-wires the globals. A tool designed for testing takes its streams as arguments: - `def initialize(out: $stdout, err: $stderr)` stores the streams, and the tool calls `@out.puts` and `@err.puts`. - Production code calls `Tally::CLI.new.run(ARGV)` and gets the real streams through the defaults. - A test passes `out: StringIO.new, err: StringIO.new` and reads them afterwards. Nothing global changes, so tests can run in parallel threads, no `ensure` bookkeeping is needed, and a forgotten restore cannot corrupt the rest of the suite. The same trick covers input: pass `in: StringIO.new("y\n")` and have the tool call `@in.gets`. ## Asserting on the captured text Once the text is in hand, a few habits keep the assertions honest: - Compare **whole strings, newlines included**: `puts` appends `"\n"`, so `"usage: tally FILE...\n"` is the value to expect, not the bare message. - Assert on **both** buffers. A tool that printed its report and also warned on `$stderr` has usually done something wrong, and an empty `err` is a meaningful check. - A tool that calls `exit` raises `SystemExit` inside the block. The `ensure` still restores the streams, but the test has to rescue `SystemExit` (or the tool should return a status instead of exiting) before it can assert on the output. - Keep the captured strings small and specific. Asserting that one key line is present is more robust than freezing an entire help screen into a test. ## Checklist for a capture helper - Always restore in `ensure`, and restore to the **saved** value rather than to `STDOUT`, so a helper nested inside another capture unwinds to the right place. - Capture `$stderr` alongside `$stdout`; usage errors and `warn` output usually matter to the assertion. - Serialize helpers that swap globals if the suite runs tests in threads; Minitest's helper takes a lock when tests run in parallel, for this reason. - Reach for a descriptor-level capture only when a child process is involved; it is slower, because it goes through the file system.

  • Why does restoring to STDOUT instead of the saved value break nested captures?
    If an outer helper has already swapped `$stdout` for its own buffer, an inner helper that restores to `STDOUT` sends the rest of the outer block's output to the real terminal, so the outer capture silently loses it. Restoring the value saved on entry unwinds each level to exactly where it started.
  • How would you feed keyboard input to a tool under test?
    Inject an input stream, `in: $stdin`, and pass `StringIO.new("yes\n")` from the test so the tool's `@in.gets` returns `"yes\n"`. Swapping `$stdin` also works for code that calls `$stdin.gets`, but bare `Kernel#gets` reads `ARGF` first, which tries to open any names left in `ARGV`.
  • Why can a global-swap capture pick up output that the tool never printed?
    `$stdout` is shared by every thread, so while the buffer is installed any other thread's `puts`, such as a background reporter or a parallel test, lands in it too. The fix is to serialize capture helpers or, better, inject per-instance streams so no global is involved.

saying these in an interview costs you the question

  • The swap captures output from system and backticks as well
  • Restoring $stdout after the assertion is enough; ensure is unnecessary
  • A constant set to $stdout at load time follows later reassignments
  • Swapping $stdout in one thread only affects that thread
  • You must call flush on the StringIO before reading its string