{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/capture-console-output/",
"description": "Test functions that write to stdout/stderr in Go by capturing output with os.Pipe. Learn patterns to avoid deadlocks in concurrent tests.",
"path": "/go/capture-console-output/",
"publishedAt": "2025-04-12T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Testing",
"TIL"
],
"textContent": "Ideally, every function that writes to the stdout probably should ask for a io.Writer and\nwrite to it instead. However, it's common to encounter functions like this:\n\nThis would be easier to test if frobnicate would ask for a writer to write to. For\ninstance:\n\nYou could pass os.Stdout to frobnicate explicitly to write to the console:\n\nThis behaves exactly the same way as the first version of frobnicate.\n\nDuring test, instead of os.Stdout, you'd just pass a bytes.Buffer and assert its content\nas follows:\n\nThis is all good. But many functions or methods that emit logs just do that directly to\nstdout. So we want to test the first version of frobnicate without making any changes to\nit.\n\nI found this neat pattern to test functions that write to stdout without accepting a writer.\n\nThe idea is to write a helper function named captureStdout that looks like this:\n\nHere's what's happening under the hood:\n\nWe use os.Pipe() to create a pipe: a connected pair of file descriptors - a reader (r)\nand a writer (w). Think of it like a temporary tunnel. Whatever we write to w, we can\nread back from r. Since both are just files as far as Go is concerned, we can temporarily\nreplace os.Stdout with the writer end of the pipe:\n\nThis means anything printed to stdout during the function run actually goes into our pipe.\nAfter the function runs, we close the writer to signal that we're done writing, then read\nfrom the reader into a buffer and restore the original stdout.\n\nNow we can test frobnicate without touching its implementation:\n\nNo need to refactor frobnicate. This works great for quick tests when you don't control\nthe code or just want to assert some printed output.\n\nA more robust capture out\n\nThe above version of captureStdout works fine for simple cases. But in practice, functions\nmight also write to stderr, especially if they're using Go's log package or if a panic\nhappens. For example, this would not be captured by the simple captureStdout helper:\n\nEven though it looks like a normal print statement, log writes to stderr by default. So\nif you want to catch that output too, or generally capture everything that's printed to the\nconsole during a function call, we need to upgrade our helper a bit. I found this example\nfrom [immudb's captureOutput helper].\n\nHere's a more complete version:\n\nThis version does a few more things:\n\n- Captures everything: It redirects both os.Stdout and os.Stderr to ensure all\n standard output streams are captured. It also explicitly redirects the standard log\n package's output, which often bypasses os.Stderr.\n\n- Prevents deadlocks: Output is read concurrently in a separate goroutine. This is\n crucial because if f generates more output than the internal pipe buffer can hold,\n writing would block without a concurrent reader, causing a deadlock.\n\n- Ensure reader readiness: A sync.WaitGroup guarantees the reading goroutine is active\n before f starts executing. This prevents a potential race condition where initial output\n could be lost if f writes before the reader is ready.\n\n- Guarantees cleanup: Using defer, the original os.Stdout and os.Stderr are always\n restored, even if f panics. This prevents the function from permanently altering the\n program's standard output streams.\n\nYou'd use captureOut the same way as the naive captureStdout. This version is safer and\nmore complete, and works well when you're testing CLI commands, log-heavy code, or anything\nthat might write to the terminal in unexpected ways.\n\nIt's not a replacement for writing functions that accept io.Writer, but when you're\ndealing with existing code or want to quickly assert on terminal output, it gets the job\ndone.\n\n\n\n\n[immudb's captureOutput helper]:\n https://github.com/codenotary/immudb/blob/cf9a5d8b9b4d3784c6b9fa8c874902bf1318a6e8/cmd/immuclient/immuclienttest/helper.go#L143",
"title": "Capturing console output in Go tests"
}