When tests scream too loud
You run go test ./... and your terminal explodes. The test suite takes forty seconds, and the output scrolls for two hundred pages. You are looking for a single panic trace, but you are buried under thousands of INFO: request received and DEBUG: cache hit messages. The signal is lost in the noise.
Go's test runner has a built-in mechanism to handle this, but it requires understanding how testing.T buffers output. Sometimes the code under test writes directly to os.Stdout, bypassing the test harness entirely. In those cases, you need to intercept the stream.
Tests should be silent when they pass. This is a core convention in Go. If a test prints output on success, it clutters the CI logs and makes real failures harder to spot. The goal is to suppress noise without altering the logic of the code being tested.
The test harness buffer
The testing package provides a T struct that manages the test lifecycle. One of its methods is Log. When you call t.Log, the message does not go to the terminal immediately. It goes into an internal buffer attached to that specific test instance.
The test runner checks the buffer only after the test function returns. If the test passed, the buffer is discarded. If the test failed, the buffer is dumped to standard error. This behavior is automatic. You get silence on success and full context on failure without writing any extra code.
package main
import (
"testing"
)
// TestSilentSuccess demonstrates that t.Log output is hidden when the test passes.
func TestSilentSuccess(t *testing.T) {
// t.Log writes to the test's internal buffer, not stdout directly.
t.Log("This message is buffered and will not appear if the test passes.")
// The test passes, so the buffer is discarded.
// Run with `go test -v` to see the buffer only on failure.
}
This is the preferred approach for most logging needs. If your code uses a logger that accepts an io.Writer, you can pass the testing.T writer directly. Many logging libraries support this pattern.
Use t.Log for all test-specific diagnostics. It keeps the output organized and tied to the test result.
Hijacking standard output
Some legacy code or third-party libraries write directly to os.Stdout using fmt.Println. These calls bypass t.Log and always appear in the terminal, even if the test passes. You cannot control this behavior from inside the test function unless you change the global state.
os.Stdout is a package-level variable of type *os.File. You can reassign it to any value that implements the io.Writer interface. The standard library provides io.Discard, a writer that accepts any input and throws it away immediately.
Swapping os.Stdout is a nuclear option. It affects the entire process, including other tests running in parallel. You must restore the original value when the test finishes. A defer statement ensures restoration even if the test panics.
package main
import (
"io"
"os"
"testing"
)
// TestSuppressStdout redirects os.Stdout to io.Discard to silence direct prints.
func TestSuppressStdout(t *testing.T) {
// Save the original stdout to restore it later.
// os.Stdout is a global variable, so we must preserve the reference.
originalStdout := os.Stdout
// Replace stdout with io.Discard, which drops all written bytes.
// This suppresses any fmt.Println calls during the test.
os.Stdout = io.Discard
// Restore the original stdout when the test function returns.
// Defer ensures cleanup happens even if the test panics.
defer func() {
os.Stdout = originalStdout
}()
// Code that writes to stdout will now be silenced.
// fmt.Println("This will not appear in the terminal.")
}
This pattern works, but it carries risks. os.Stdout is not safe for concurrent modification. If your code spawns goroutines that write to stdout, you might race with the restoration logic. One goroutine might write to io.Discard while another writes to the restored os.Stdout, or vice versa. The output becomes non-deterministic.
If you use this pattern, ensure the code under test does not spawn long-lived goroutines that write to standard output. Restore the stream as quickly as possible.
Global state manipulation is a last resort. Prefer dependency injection or logger configuration when available.
Filtering with a custom writer
Sometimes you do not want to suppress all output. You might want to hide DEBUG lines but keep ERROR lines visible. Or you might want to capture specific output to assert against it later.
You can create a custom type that implements the io.Writer interface. The interface requires a single method: Write(p []byte) (n int, err error). Your implementation can inspect the bytes, decide whether to pass them through, and return the count.
This approach gives you fine-grained control. You can filter by prefix, substring, or regex. You can also count lines, measure volume, or store the output for assertions.
package main
import (
"io"
"os"
"strings"
"testing"
)
// FilterWriter implements io.Writer to drop lines containing a specific substring.
type FilterWriter struct {
// target is the underlying writer, usually the original os.Stdout.
target io.Writer
// dropPrefix defines the string that triggers suppression.
dropPrefix string
}
// Write implements the io.Writer interface.
// It checks each write call and discards data if it matches the filter.
func (fw *FilterWriter) Write(p []byte) (n int, err error) {
// Convert bytes to string for inspection.
// This allocates, but is acceptable for test filtering overhead.
content := string(p)
// Check if the content starts with the forbidden prefix.
// If it matches, drop the data and return success.
if strings.HasPrefix(content, fw.dropPrefix) {
return len(p), nil
}
// Pass the data through to the target writer.
// Return the number of bytes written to satisfy the interface.
return fw.target.Write(p)
}
// TestFilterLogs demonstrates filtering specific log lines while keeping others.
func TestFilterLogs(t *testing.T) {
// Create a filter that drops lines starting with "DEBUG:".
filter := &FilterWriter{
target: os.Stdout,
dropPrefix: "DEBUG:",
}
// Save original stdout and restore it on exit.
originalStdout := os.Stdout
os.Stdout = filter
defer func() {
os.Stdout = originalStdout
}()
// Simulate code that writes mixed log levels.
// Only ERROR lines will appear in the terminal.
// os.Stdout.Write([]byte("DEBUG: cache miss\n"))
// os.Stdout.Write([]byte("ERROR: connection refused\n"))
}
The Write method is called for every write operation. If the underlying code writes small chunks, you might get partial lines. For robust filtering, you might need a bufio.Writer or a custom buffer that assembles full lines before checking. For simple tests, checking the raw bytes is usually sufficient.
Convention aside: The io.Writer interface is one of the most important interfaces in Go. It powers fmt, os, net, and testing. Implementing it correctly means returning the number of bytes written and any error. Returning len(p) on success is standard practice.
Custom writers add complexity. Use them only when you need selective filtering. Full suppression is simpler and safer.
Pitfalls and compiler errors
Redirecting output introduces subtle bugs. The most common issue is forgetting to restore the original stream. If a test panics before the defer runs, or if you forget the defer entirely, subsequent tests might run with os.Stdout pointing to io.Discard or a closed file. This breaks the entire test suite.
The compiler helps catch interface mismatches. If you try to assign a type to os.Stdout that does not implement io.Writer, the build fails.
The compiler rejects the assignment with
cannot use writer (variable of type *MyWriter) as io.Writer value in assignment: missing method Writeif your type lacks the required method.
Ensure your custom writer implements Write with the exact signature: func (w *Writer) Write(p []byte) (int, error). The receiver can be a pointer or value, but the method must match.
Another pitfall is goroutine leaks. If the code under test starts a goroutine that writes to stdout, and you suppress stdout, the goroutine might block waiting for the writer to accept data. io.Discard never blocks, but a custom writer that buffers or locks might. Always ensure your writer is non-blocking or has a cancellation path.
Goroutine leaks in tests are hard to debug. The test might hang indefinitely. Use t.Cleanup to register teardown functions that run after the test completes, even on panic. This is safer than defer in some complex scenarios.
Use
t.Cleanupwhen you need to guarantee teardown logic runs after the test, regardless of panic or early return.
Decision matrix
Choose the suppression strategy based on the source of the output and the level of control you need.
Use t.Log when you want output only on failure and the code supports logger injection. Use os.Stdout redirection to io.Discard when the code writes directly to standard output and you need to silence everything. Use a custom io.Writer when you need to filter specific log levels while keeping others visible. Use t.Cleanup when you need to register teardown logic that runs after the test completes. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.