Test Logging Output in Go With Custom slog Handlers
Assert on log output in Go tests with a small slog.Handler that stores records, then read level, message and attributes without parsing any text.

Logs are an interface. Operators alert on them and dashboards count them, and a refactor that drops the order attribute breaks something nobody tested. Asserting on log output is worth doing, and parsing the text of a log line is the wrong way. Go 1.21's log/slog makes the right way short: write a handler that keeps the records.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
package main
import (
"context"
"log/slog"
"sync"
)
// Capture is a slog.Handler that keeps records in memory for assertions.
type Capture struct {
mu *sync.Mutex
Records *[]slog.Record
attrs []slog.Attr
}
func NewCapture() *Capture { return &Capture{mu: &sync.Mutex{}, Records: new([]slog.Record)} }
func (c *Capture) Enabled(context.Context, slog.Level) bool { return true }
func (c *Capture) Handle(_ context.Context, r slog.Record) error {
c.mu.Lock()
defer c.mu.Unlock()
r = r.Clone()
r.AddAttrs(c.attrs...)
*c.Records = append(*c.Records, r)
return nil
}
func (c *Capture) WithAttrs(a []slog.Attr) slog.Handler {
n := *c
n.attrs = append(append([]slog.Attr{}, c.attrs...), a...)
return &n
}
func (c *Capture) WithGroup(string) slog.Handler { return c }
// Charge logs what a payment service would.
func Charge(log *slog.Logger, id string, cents int) {
log.Info("charge accepted", "order", id, "cents", cents)
if cents > 100000 {
log.Warn("large charge", "order", id)
}
}
func main() {}package main
import (
"log/slog"
"testing"
)
func TestChargeLogs(t *testing.T) {
h := NewCapture()
log := slog.New(h).With("service", "payments")
Charge(log, "o-1", 250000)
for _, r := range *h.Records {
attrs := map[string]any{}
r.Attrs(func(a slog.Attr) bool { attrs[a.Key] = a.Value.Any(); return true })
t.Logf("%s %q %v", r.Level, r.Message, attrs)
}
t.Logf("records: %d", len(*h.Records))
}go test -count=1 -v . 2>&1 | grep -vE "^(=== RUN|=== PAUSE|=== CONT|PASS|ok)" | sed -E "s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #" INFO "charge accepted" map[cents:250000 order:o-1 service:payments]
WARN "large charge" map[order:o-1 service:payments]
records: 2
--- PASS: TestChargeLogsWhat the handler does
Capture implements the four methods of slog.Handler. Enabled returns true, so every level is recorded. Handle appends a clone of the record under a mutex. WithAttrs returns a new handler that carries the extra attributes, which is how logger.With("service", "payments") works, and WithGroup is a no-op here; add real group support if your code logs with groups.
The test builds slog.New(h).With("service", "payments"), calls the function, and reads *h.Records. Each slog.Record has Level, Message and Attrs, so assertions are direct: the first record is INFO with the message charge accepted, and its attributes include cents:250000, order:o-1 and the inherited service:payments. The second is a WARN. Two records in total, which the log confirms.
Two details cause bugs. r.Clone() matters because a record's attributes may live in storage that the logger reuses; keep a record without cloning and it can change under you. And WithAttrs must copy the slice, as the lab does, so two loggers derived from one handler do not share and overwrite attributes.
Why assert on logs at all
A log line is a contract with whoever reads it. Alerts match on a message, dashboards count records at a level, and an incident responder searches for an order ID. A change that renames the attribute order to order_id, or lowers an error to a warning, breaks those consumers without breaking any other test. Asserting on a small number of important records, the ones that alerts depend on, keeps the contract honest, as the testing hub argues for observable behaviour in general. Do not assert on every line: tests that pin all of the log output break on each message tweak and train people to update them blindly.
A good rule is to test the level, the message key words and the identifying attributes, and to leave the rest of the text alone. The table-driven style from table tests fits when a function logs differently for several inputs.
Validate a handler you wrote
If you ship your own handler, the standard library has testing/slogtest, whose TestHandler runs a battery of checks against the slog.Handler contract: groups, attributes, times, empty values. Use it for the handler itself, and use the capture handler for application code.
For the standard handlers, a bytes.Buffer with slog.NewJSONHandler works when you need the exact serialised form, and you should remove the time attribute with ReplaceAttr so output is stable. The related reading is the log/slog package documentation and the slogtest reference.


