package test // Package test provides mocks and helpers for testing code that uses slog logging. // // It includes MockHandler, a thread-safe slog.Handler implementation for capturing log records in tests. import ( "context" "log/slog" "slices" "sync" "testing" ) type logRecorder struct { mu sync.Mutex records []slog.Record } func (h *logRecorder) Append(r slog.Record) { h.mu.Lock() defer h.mu.Unlock() h.records = append(h.records, r.Clone()) } func (h *logRecorder) Records() []slog.Record { h.mu.Lock() defer h.mu.Unlock() return slices.Clone(h.records) } // MockHandler is a slog.Handler that records log records for testing. // // It supports attribute and group chaining like slog's built-in handlers. // Use NewMockLogHandler to construct one, then pass it to slog.New in your tests. // // All recorded logs can be retrieved with the Records method. type MockHandler struct { recorder *logRecorder parent *MockHandler group string attrs []slog.Attr } var _ slog.Handler = &MockHandler{} // NewMockLogHandler creates a new MockHandler for use in tests. func NewMockLogHandler(tb testing.TB) *MockHandler { tb.Helper() return &MockHandler{ recorder: &logRecorder{}, } } func (h *MockHandler) Enabled(ctx context.Context, level slog.Level) bool { return true // Capture all logs } func normalizeAttrs(attrs []slog.Attr) []slog.Attr { var out []slog.Attr for _, attr := range attrs { if attr.Equal(slog.Attr{}) { continue } v := attr.Value.Resolve() switch v.Kind() { case slog.KindGroup: // Skip empty group if len(v.Group()) == 0 { continue } inner := normalizeAttrs(v.Group()) // Flatten if group key is empty if attr.Key == "" { out = append(out, inner...) } else { out = append(out, slog.Attr{ Key: attr.Key, Value: slog.GroupValue(inner...), }) } default: out = append(out, slog.Attr{ Key: attr.Key, Value: v, }) } } return out } func (h *MockHandler) Handle(ctx context.Context, r slog.Record) error { newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) if h.parent == nil { attrs := []slog.Attr{} r.Attrs(func(attr slog.Attr) bool { attrs = append(attrs, attr) return true }) attrs = normalizeAttrs(attrs) newRecord.AddAttrs(attrs...) h.recorder.Append(newRecord) return nil } attrs := slices.Clone(h.attrs) r.Attrs(func(attr slog.Attr) bool { attrs = append(attrs, attr) return true }) attrs = normalizeAttrs(attrs) if h.group != "" { newRecord.AddAttrs(slog.Attr{ Key: h.group, Value: slog.GroupValue(attrs...), }) } else { newRecord.AddAttrs(attrs...) } return h.parent.Handle(ctx, newRecord) } func (h *MockHandler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } return &MockHandler{ recorder: h.recorder, parent: h, attrs: attrs, } } func (h *MockHandler) WithGroup(name string) slog.Handler { if name == "" { return h } return &MockHandler{ recorder: h.recorder, parent: h, group: name, } } // Records returns a copy of all slog.Records captured by this handler so far. // // It is safe to call from multiple goroutines. func (h *MockHandler) Records() []slog.Record { return h.recorder.Records() }