]> Cypherpunks.ru repositories - gostls13.git/blob - src/testing/slogtest/slogtest.go
testing: add available godoc link
[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         "testing"
16         "time"
17 )
18
19 type 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
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 // 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.
251 func 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.
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 }