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: "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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251 func TestHandler(h slog.Handler, results func() []map[string]any) error {
252
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
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
280
281
282
283 func 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
302 type check func(map[string]any) string
303
304 func 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
313 func 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
322 func 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
335 func 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
349 type wrapper struct {
350 slog.Handler
351 mod func(*slog.Record)
352 }
353
354 func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
355 h.mod(&r)
356 return h.Handler.Handle(ctx, r)
357 }
358
359 func 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
367 type replace struct {
368 v any
369 }
370
371 func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
372
373 func (r *replace) String() string {
374 return fmt.Sprintf("<replace(%v)>", r.v)
375 }
376
View as plain text