1
2
3
4
5
6 package slogtest
7
8 import (
9 "context"
10 "errors"
11 "fmt"
12 "log/slog"
13 "reflect"
14 "runtime"
15 "testing"
16 "time"
17 )
18
19 type testCase struct {
20
21 name string
22
23 explanation string
24
25
26
27
28 f func(*slog.Logger)
29
30
31 mod func(*slog.Record)
32
33 checks []check
34 }
35
36 var 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: "nested-empty-group-record",
176 explanation: withSource("a Handler should not output nested groups if there are no attributes"),
177 f: func(l *slog.Logger) {
178 l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").WithGroup("I").Info("msg")
179 },
180 checks: []check{
181 hasKey(slog.TimeKey),
182 hasKey(slog.LevelKey),
183 hasAttr(slog.MessageKey, "msg"),
184 hasAttr("a", "b"),
185 inGroup("G", hasAttr("c", "d")),
186 inGroup("G", missingKey("H")),
187 inGroup("G", missingKey("I")),
188 },
189 },
190 {
191 name: "resolve",
192 explanation: withSource("a Handler should call Resolve on attribute values"),
193 f: func(l *slog.Logger) {
194 l.Info("msg", "k", &replace{"replaced"})
195 },
196 checks: []check{hasAttr("k", "replaced")},
197 },
198 {
199 name: "resolve-groups",
200 explanation: withSource("a Handler should call Resolve on attribute values in groups"),
201 f: func(l *slog.Logger) {
202 l.Info("msg",
203 slog.Group("G",
204 slog.String("a", "v1"),
205 slog.Any("b", &replace{"v2"})))
206 },
207 checks: []check{
208 inGroup("G", hasAttr("a", "v1")),
209 inGroup("G", hasAttr("b", "v2")),
210 },
211 },
212 {
213 name: "resolve-WithAttrs",
214 explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"),
215 f: func(l *slog.Logger) {
216 l = l.With("k", &replace{"replaced"})
217 l.Info("msg")
218 },
219 checks: []check{hasAttr("k", "replaced")},
220 },
221 {
222 name: "resolve-WithAttrs-groups",
223 explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"),
224 f: func(l *slog.Logger) {
225 l = l.With(slog.Group("G",
226 slog.String("a", "v1"),
227 slog.Any("b", &replace{"v2"})))
228 l.Info("msg")
229 },
230 checks: []check{
231 inGroup("G", hasAttr("a", "v1")),
232 inGroup("G", hasAttr("b", "v2")),
233 },
234 },
235 {
236 name: "empty-PC",
237 explanation: withSource("a Handler should not output SourceKey if the PC is zero"),
238 f: func(l *slog.Logger) {
239 l.Info("message")
240 },
241 mod: func(r *slog.Record) { r.PC = 0 },
242 checks: []check{
243 missingKey(slog.SourceKey),
244 },
245 },
246 }
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267 func TestHandler(h slog.Handler, results func() []map[string]any) error {
268
269 for _, c := range cases {
270 ht := h
271 if c.mod != nil {
272 ht = &wrapper{h, c.mod}
273 }
274 l := slog.New(ht)
275 c.f(l)
276 }
277
278
279 var errs []error
280 res := results()
281 if g, w := len(res), len(cases); g != w {
282 return fmt.Errorf("got %d results, want %d", g, w)
283 }
284 for i, got := range res {
285 c := cases[i]
286 for _, check := range c.checks {
287 if problem := check(got); problem != "" {
288 errs = append(errs, fmt.Errorf("%s: %s", problem, c.explanation))
289 }
290 }
291 }
292 return errors.Join(errs...)
293 }
294
295
296
297
298
299 func Run(t *testing.T, newHandler func(*testing.T) slog.Handler, result func(*testing.T) map[string]any) {
300 for _, c := range cases {
301 t.Run(c.name, func(t *testing.T) {
302 h := newHandler(t)
303 if c.mod != nil {
304 h = &wrapper{h, c.mod}
305 }
306 l := slog.New(h)
307 c.f(l)
308 got := result(t)
309 for _, check := range c.checks {
310 if p := check(got); p != "" {
311 t.Errorf("%s: %s", p, c.explanation)
312 }
313 }
314 })
315 }
316 }
317
318 type check func(map[string]any) string
319
320 func hasKey(key string) check {
321 return func(m map[string]any) string {
322 if _, ok := m[key]; !ok {
323 return fmt.Sprintf("missing key %q", key)
324 }
325 return ""
326 }
327 }
328
329 func missingKey(key string) check {
330 return func(m map[string]any) string {
331 if _, ok := m[key]; ok {
332 return fmt.Sprintf("unexpected key %q", key)
333 }
334 return ""
335 }
336 }
337
338 func hasAttr(key string, wantVal any) check {
339 return func(m map[string]any) string {
340 if s := hasKey(key)(m); s != "" {
341 return s
342 }
343 gotVal := m[key]
344 if !reflect.DeepEqual(gotVal, wantVal) {
345 return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal)
346 }
347 return ""
348 }
349 }
350
351 func inGroup(name string, c check) check {
352 return func(m map[string]any) string {
353 v, ok := m[name]
354 if !ok {
355 return fmt.Sprintf("missing group %q", name)
356 }
357 g, ok := v.(map[string]any)
358 if !ok {
359 return fmt.Sprintf("value for group %q is not map[string]any", name)
360 }
361 return c(g)
362 }
363 }
364
365 type wrapper struct {
366 slog.Handler
367 mod func(*slog.Record)
368 }
369
370 func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
371 h.mod(&r)
372 return h.Handler.Handle(ctx, r)
373 }
374
375 func withSource(s string) string {
376 _, file, line, ok := runtime.Caller(1)
377 if !ok {
378 panic("runtime.Caller failed")
379 }
380 return fmt.Sprintf("%s (%s:%d)", s, file, line)
381 }
382
383 type replace struct {
384 v any
385 }
386
387 func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
388
389 func (r *replace) String() string {
390 return fmt.Sprintf("<replace(%v)>", r.v)
391 }
392
View as plain text