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