1// Copyright 2023 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Package slogtest implements support for testing implementations of log/slog.Handler.
6package slogtest
7
8import (
9	"context"
10	"errors"
11	"fmt"
12	"log/slog"
13	"reflect"
14	"runtime"
15	"testing"
16	"time"
17)
18
19type testCase struct {
20	// Subtest name.
21	name string
22	// If non-empty, explanation explains the violated constraint.
23	explanation string
24	// f executes a single log event using its argument logger.
25	// So that mkdescs.sh can generate the right description,
26	// the body of f must appear on a single line whose first
27	// non-whitespace characters are "l.".
28	f func(*slog.Logger)
29	// If mod is not nil, it is called to modify the Record
30	// generated by the Logger before it is passed to the Handler.
31	mod func(*slog.Record)
32	// checks is a list of checks to run on the result.
33	checks []check
34}
35
36var cases = []testCase{
37	{
38		name:        "built-ins",
39		explanation: withSource("this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey"),
40		f: func(l *slog.Logger) {
41			l.Info("message")
42		},
43		checks: []check{
44			hasKey(slog.TimeKey),
45			hasKey(slog.LevelKey),
46			hasAttr(slog.MessageKey, "message"),
47		},
48	},
49	{
50		name:        "attrs",
51		explanation: withSource("a Handler should output attributes passed to the logging function"),
52		f: func(l *slog.Logger) {
53			l.Info("message", "k", "v")
54		},
55		checks: []check{
56			hasAttr("k", "v"),
57		},
58	},
59	{
60		name:        "empty-attr",
61		explanation: withSource("a Handler should ignore an empty Attr"),
62		f: func(l *slog.Logger) {
63			l.Info("msg", "a", "b", "", nil, "c", "d")
64		},
65		checks: []check{
66			hasAttr("a", "b"),
67			missingKey(""),
68			hasAttr("c", "d"),
69		},
70	},
71	{
72		name:        "zero-time",
73		explanation: withSource("a Handler should ignore a zero Record.Time"),
74		f: func(l *slog.Logger) {
75			l.Info("msg", "k", "v")
76		},
77		mod: func(r *slog.Record) { r.Time = time.Time{} },
78		checks: []check{
79			missingKey(slog.TimeKey),
80		},
81	},
82	{
83		name:        "WithAttrs",
84		explanation: withSource("a Handler should include the attributes from the WithAttrs method"),
85		f: func(l *slog.Logger) {
86			l.With("a", "b").Info("msg", "k", "v")
87		},
88		checks: []check{
89			hasAttr("a", "b"),
90			hasAttr("k", "v"),
91		},
92	},
93	{
94		name:        "groups",
95		explanation: withSource("a Handler should handle Group attributes"),
96		f: func(l *slog.Logger) {
97			l.Info("msg", "a", "b", slog.Group("G", slog.String("c", "d")), "e", "f")
98		},
99		checks: []check{
100			hasAttr("a", "b"),
101			inGroup("G", hasAttr("c", "d")),
102			hasAttr("e", "f"),
103		},
104	},
105	{
106		name:        "empty-group",
107		explanation: withSource("a Handler should ignore an empty group"),
108		f: func(l *slog.Logger) {
109			l.Info("msg", "a", "b", slog.Group("G"), "e", "f")
110		},
111		checks: []check{
112			hasAttr("a", "b"),
113			missingKey("G"),
114			hasAttr("e", "f"),
115		},
116	},
117	{
118		name:        "inline-group",
119		explanation: withSource("a Handler should inline the Attrs of a group with an empty key"),
120		f: func(l *slog.Logger) {
121			l.Info("msg", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f")
122
123		},
124		checks: []check{
125			hasAttr("a", "b"),
126			hasAttr("c", "d"),
127			hasAttr("e", "f"),
128		},
129	},
130	{
131		name:        "WithGroup",
132		explanation: withSource("a Handler should handle the WithGroup method"),
133		f: func(l *slog.Logger) {
134			l.WithGroup("G").Info("msg", "a", "b")
135		},
136		checks: []check{
137			hasKey(slog.TimeKey),
138			hasKey(slog.LevelKey),
139			hasAttr(slog.MessageKey, "msg"),
140			missingKey("a"),
141			inGroup("G", hasAttr("a", "b")),
142		},
143	},
144	{
145		name:        "multi-With",
146		explanation: withSource("a Handler should handle multiple WithGroup and WithAttr calls"),
147		f: func(l *slog.Logger) {
148			l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg", "e", "f")
149		},
150		checks: []check{
151			hasKey(slog.TimeKey),
152			hasKey(slog.LevelKey),
153			hasAttr(slog.MessageKey, "msg"),
154			hasAttr("a", "b"),
155			inGroup("G", hasAttr("c", "d")),
156			inGroup("G", inGroup("H", hasAttr("e", "f"))),
157		},
158	},
159	{
160		name:        "empty-group-record",
161		explanation: withSource("a Handler should not output groups if there are no attributes"),
162		f: func(l *slog.Logger) {
163			l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg")
164		},
165		checks: []check{
166			hasKey(slog.TimeKey),
167			hasKey(slog.LevelKey),
168			hasAttr(slog.MessageKey, "msg"),
169			hasAttr("a", "b"),
170			inGroup("G", hasAttr("c", "d")),
171			inGroup("G", missingKey("H")),
172		},
173	},
174	{
175		name:        "resolve",
176		explanation: withSource("a Handler should call Resolve on attribute values"),
177		f: func(l *slog.Logger) {
178			l.Info("msg", "k", &replace{"replaced"})
179		},
180		checks: []check{hasAttr("k", "replaced")},
181	},
182	{
183		name:        "resolve-groups",
184		explanation: withSource("a Handler should call Resolve on attribute values in groups"),
185		f: func(l *slog.Logger) {
186			l.Info("msg",
187				slog.Group("G",
188					slog.String("a", "v1"),
189					slog.Any("b", &replace{"v2"})))
190		},
191		checks: []check{
192			inGroup("G", hasAttr("a", "v1")),
193			inGroup("G", hasAttr("b", "v2")),
194		},
195	},
196	{
197		name:        "resolve-WithAttrs",
198		explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"),
199		f: func(l *slog.Logger) {
200			l = l.With("k", &replace{"replaced"})
201			l.Info("msg")
202		},
203		checks: []check{hasAttr("k", "replaced")},
204	},
205	{
206		name:        "resolve-WithAttrs-groups",
207		explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"),
208		f: func(l *slog.Logger) {
209			l = l.With(slog.Group("G",
210				slog.String("a", "v1"),
211				slog.Any("b", &replace{"v2"})))
212			l.Info("msg")
213		},
214		checks: []check{
215			inGroup("G", hasAttr("a", "v1")),
216			inGroup("G", hasAttr("b", "v2")),
217		},
218	},
219	{
220		name:        "empty-PC",
221		explanation: withSource("a Handler should not output SourceKey if the PC is zero"),
222		f: func(l *slog.Logger) {
223			l.Info("message")
224		},
225		mod: func(r *slog.Record) { r.PC = 0 },
226		checks: []check{
227			missingKey(slog.SourceKey),
228		},
229	},
230}
231
232// TestHandler tests a [slog.Handler].
233// If TestHandler finds any misbehaviors, it returns an error for each,
234// combined into a single error with [errors.Join].
235//
236// TestHandler installs the given Handler in a [slog.Logger] and
237// makes several calls to the Logger's output methods.
238// The Handler should be enabled for levels Info and above.
239//
240// The results function is invoked after all such calls.
241// It should return a slice of map[string]any, one for each call to a Logger output method.
242// The keys and values of the map should correspond to the keys and values of the Handler's
243// output. Each group in the output should be represented as its own nested map[string]any.
244// The standard keys [slog.TimeKey], [slog.LevelKey] and [slog.MessageKey] should be used.
245//
246// If the Handler outputs JSON, then calling [encoding/json.Unmarshal] with a `map[string]any`
247// will create the right data structure.
248//
249// If a Handler intentionally drops an attribute that is checked by a test,
250// then the results function should check for its absence and add it to the map it returns.
251func TestHandler(h slog.Handler, results func() []map[string]any) error {
252	// Run the handler on the test cases.
253	for _, c := range cases {
254		ht := h
255		if c.mod != nil {
256			ht = &wrapper{h, c.mod}
257		}
258		l := slog.New(ht)
259		c.f(l)
260	}
261
262	// Collect and check the results.
263	var errs []error
264	res := results()
265	if g, w := len(res), len(cases); g != w {
266		return fmt.Errorf("got %d results, want %d", g, w)
267	}
268	for i, got := range results() {
269		c := cases[i]
270		for _, check := range c.checks {
271			if problem := check(got); problem != "" {
272				errs = append(errs, fmt.Errorf("%s: %s", problem, c.explanation))
273			}
274		}
275	}
276	return errors.Join(errs...)
277}
278
279// Run exercises a [slog.Handler] on the same test cases as [TestHandler], but
280// runs each case in a subtest. For each test case, it first calls newHandler to
281// get an instance of the handler under test, then runs the test case, then
282// calls result to get the result. If the test case fails, it calls t.Error.
283func Run(t *testing.T, newHandler func(*testing.T) slog.Handler, result func(*testing.T) map[string]any) {
284	for _, c := range cases {
285		t.Run(c.name, func(t *testing.T) {
286			h := newHandler(t)
287			if c.mod != nil {
288				h = &wrapper{h, c.mod}
289			}
290			l := slog.New(h)
291			c.f(l)
292			got := result(t)
293			for _, check := range c.checks {
294				if p := check(got); p != "" {
295					t.Errorf("%s: %s", p, c.explanation)
296				}
297			}
298		})
299	}
300}
301
302type check func(map[string]any) string
303
304func hasKey(key string) check {
305	return func(m map[string]any) string {
306		if _, ok := m[key]; !ok {
307			return fmt.Sprintf("missing key %q", key)
308		}
309		return ""
310	}
311}
312
313func missingKey(key string) check {
314	return func(m map[string]any) string {
315		if _, ok := m[key]; ok {
316			return fmt.Sprintf("unexpected key %q", key)
317		}
318		return ""
319	}
320}
321
322func hasAttr(key string, wantVal any) check {
323	return func(m map[string]any) string {
324		if s := hasKey(key)(m); s != "" {
325			return s
326		}
327		gotVal := m[key]
328		if !reflect.DeepEqual(gotVal, wantVal) {
329			return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal)
330		}
331		return ""
332	}
333}
334
335func inGroup(name string, c check) check {
336	return func(m map[string]any) string {
337		v, ok := m[name]
338		if !ok {
339			return fmt.Sprintf("missing group %q", name)
340		}
341		g, ok := v.(map[string]any)
342		if !ok {
343			return fmt.Sprintf("value for group %q is not map[string]any", name)
344		}
345		return c(g)
346	}
347}
348
349type wrapper struct {
350	slog.Handler
351	mod func(*slog.Record)
352}
353
354func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
355	h.mod(&r)
356	return h.Handler.Handle(ctx, r)
357}
358
359func withSource(s string) string {
360	_, file, line, ok := runtime.Caller(1)
361	if !ok {
362		panic("runtime.Caller failed")
363	}
364	return fmt.Sprintf("%s (%s:%d)", s, file, line)
365}
366
367type replace struct {
368	v any
369}
370
371func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
372
373func (r *replace) String() string {
374	return fmt.Sprintf("<replace(%v)>", r.v)
375}
376