]> Cypherpunks.ru repositories - gostls13.git/blob - src/testing/slogtest/slogtest.go
testing/slogtest: tests for slog handlers
[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 call Resolve on attribute values"),
167                         f: func(l *slog.Logger) {
168                                 l.Info("msg", "k", &replace{"replaced"})
169                         },
170                         checks: []check{hasAttr("k", "replaced")},
171                 },
172                 {
173                         explanation: withSource("a Handler should call Resolve on attribute values in groups"),
174                         f: func(l *slog.Logger) {
175                                 l.Info("msg",
176                                         slog.Group("G",
177                                                 slog.String("a", "v1"),
178                                                 slog.Any("b", &replace{"v2"})))
179                         },
180                         checks: []check{
181                                 inGroup("G", hasAttr("a", "v1")),
182                                 inGroup("G", hasAttr("b", "v2")),
183                         },
184                 },
185                 {
186                         explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"),
187                         f: func(l *slog.Logger) {
188                                 l = l.With("k", &replace{"replaced"})
189                                 l.Info("msg")
190                         },
191                         checks: []check{hasAttr("k", "replaced")},
192                 },
193                 {
194                         explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"),
195                         f: func(l *slog.Logger) {
196                                 l = l.With(slog.Group("G",
197                                         slog.String("a", "v1"),
198                                         slog.Any("b", &replace{"v2"})))
199                                 l.Info("msg")
200                         },
201                         checks: []check{
202                                 inGroup("G", hasAttr("a", "v1")),
203                                 inGroup("G", hasAttr("b", "v2")),
204                         },
205                 },
206         }
207
208         // Run the handler on the test cases.
209         for _, c := range cases {
210                 ht := h
211                 if c.mod != nil {
212                         ht = &wrapper{h, c.mod}
213                 }
214                 l := slog.New(ht)
215                 c.f(l)
216         }
217
218         // Collect and check the results.
219         var errs []error
220         res := results()
221         if g, w := len(res), len(cases); g != w {
222                 return fmt.Errorf("got %d results, want %d", g, w)
223         }
224         for i, got := range results() {
225                 c := cases[i]
226                 for _, check := range c.checks {
227                         if p := check(got); p != "" {
228                                 errs = append(errs, fmt.Errorf("%s: %s", p, c.explanation))
229                         }
230                 }
231         }
232         return errors.Join(errs...)
233 }
234
235 type check func(map[string]any) string
236
237 func hasKey(key string) check {
238         return func(m map[string]any) string {
239                 if _, ok := m[key]; !ok {
240                         return fmt.Sprintf("missing key %q", key)
241                 }
242                 return ""
243         }
244 }
245
246 func missingKey(key string) check {
247         return func(m map[string]any) string {
248                 if _, ok := m[key]; ok {
249                         return fmt.Sprintf("unexpected key %q", key)
250                 }
251                 return ""
252         }
253 }
254
255 func hasAttr(key string, wantVal any) check {
256         return func(m map[string]any) string {
257                 if s := hasKey(key)(m); s != "" {
258                         return s
259                 }
260                 gotVal := m[key]
261                 if !reflect.DeepEqual(gotVal, wantVal) {
262                         return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal)
263                 }
264                 return ""
265         }
266 }
267
268 func inGroup(name string, c check) check {
269         return func(m map[string]any) string {
270                 v, ok := m[name]
271                 if !ok {
272                         return fmt.Sprintf("missing group %q", name)
273                 }
274                 g, ok := v.(map[string]any)
275                 if !ok {
276                         return fmt.Sprintf("value for group %q is not map[string]any", name)
277                 }
278                 return c(g)
279         }
280 }
281
282 type wrapper struct {
283         slog.Handler
284         mod func(*slog.Record)
285 }
286
287 func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
288         h.mod(&r)
289         return h.Handler.Handle(ctx, r)
290 }
291
292 func withSource(s string) string {
293         _, file, line, ok := runtime.Caller(1)
294         if !ok {
295                 panic("runtime.Caller failed")
296         }
297         return fmt.Sprintf("%s (%s:%d)", s, file, line)
298 }
299
300 type replace struct {
301         v any
302 }
303
304 func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
305
306 func (r *replace) String() string {
307         return fmt.Sprintf("<replace(%v)>", r.v)
308 }