]> Cypherpunks.ru repositories - gostls13.git/blob - src/testing/slogtest/slogtest.go
testing/slogtest: add Run to run cases as subtests
[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
221 // TestHandler tests a [slog.Handler].
222 // If TestHandler finds any misbehaviors, it returns an error for each,
223 // combined into a single error with errors.Join.
224 //
225 // TestHandler installs the given Handler in a [slog.Logger] and
226 // makes several calls to the Logger's output methods.
227 // The Handler should be enabled for levels Info and above.
228 //
229 // The results function is invoked after all such calls.
230 // It should return a slice of map[string]any, one for each call to a Logger output method.
231 // The keys and values of the map should correspond to the keys and values of the Handler's
232 // output. Each group in the output should be represented as its own nested map[string]any.
233 // The standard keys slog.TimeKey, slog.LevelKey and slog.MessageKey should be used.
234 //
235 // If the Handler outputs JSON, then calling [encoding/json.Unmarshal] with a `map[string]any`
236 // will create the right data structure.
237 //
238 // If a Handler intentionally drops an attribute that is checked by a test,
239 // then the results function should check for its absence and add it to the map it returns.
240 func TestHandler(h slog.Handler, results func() []map[string]any) error {
241         // Run the handler on the test cases.
242         for _, c := range cases {
243                 ht := h
244                 if c.mod != nil {
245                         ht = &wrapper{h, c.mod}
246                 }
247                 l := slog.New(ht)
248                 c.f(l)
249         }
250
251         // Collect and check the results.
252         var errs []error
253         res := results()
254         if g, w := len(res), len(cases); g != w {
255                 return fmt.Errorf("got %d results, want %d", g, w)
256         }
257         for i, got := range results() {
258                 c := cases[i]
259                 for _, check := range c.checks {
260                         if problem := check(got); problem != "" {
261                                 errs = append(errs, fmt.Errorf("%s: %s", problem, c.explanation))
262                         }
263                 }
264         }
265         return errors.Join(errs...)
266 }
267
268 // Run exercises a [slog.Handler] on the same test cases as [TestHandler], but
269 // runs each case in a subtest. For each test case, it first calls newHandler to
270 // get an instance of the handler under test, then runs the test case, then
271 // calls result to get the result. If the test case fails, it calls t.Error.
272 func Run(t *testing.T, newHandler func(*testing.T) slog.Handler, result func(*testing.T) map[string]any) {
273         for _, c := range cases {
274                 t.Run(c.name, func(t *testing.T) {
275                         h := newHandler(t)
276                         if c.mod != nil {
277                                 h = &wrapper{h, c.mod}
278                         }
279                         l := slog.New(h)
280                         c.f(l)
281                         got := result(t)
282                         for _, check := range c.checks {
283                                 if p := check(got); p != "" {
284                                         t.Errorf("%s: %s", p, c.explanation)
285                                 }
286                         }
287                 })
288         }
289 }
290
291 type check func(map[string]any) string
292
293 func hasKey(key string) check {
294         return func(m map[string]any) string {
295                 if _, ok := m[key]; !ok {
296                         return fmt.Sprintf("missing key %q", key)
297                 }
298                 return ""
299         }
300 }
301
302 func missingKey(key string) check {
303         return func(m map[string]any) string {
304                 if _, ok := m[key]; ok {
305                         return fmt.Sprintf("unexpected key %q", key)
306                 }
307                 return ""
308         }
309 }
310
311 func hasAttr(key string, wantVal any) check {
312         return func(m map[string]any) string {
313                 if s := hasKey(key)(m); s != "" {
314                         return s
315                 }
316                 gotVal := m[key]
317                 if !reflect.DeepEqual(gotVal, wantVal) {
318                         return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal)
319                 }
320                 return ""
321         }
322 }
323
324 func inGroup(name string, c check) check {
325         return func(m map[string]any) string {
326                 v, ok := m[name]
327                 if !ok {
328                         return fmt.Sprintf("missing group %q", name)
329                 }
330                 g, ok := v.(map[string]any)
331                 if !ok {
332                         return fmt.Sprintf("value for group %q is not map[string]any", name)
333                 }
334                 return c(g)
335         }
336 }
337
338 type wrapper struct {
339         slog.Handler
340         mod func(*slog.Record)
341 }
342
343 func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
344         h.mod(&r)
345         return h.Handler.Handle(ctx, r)
346 }
347
348 func withSource(s string) string {
349         _, file, line, ok := runtime.Caller(1)
350         if !ok {
351                 panic("runtime.Caller failed")
352         }
353         return fmt.Sprintf("%s (%s:%d)", s, file, line)
354 }
355
356 type replace struct {
357         v any
358 }
359
360 func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
361
362 func (r *replace) String() string {
363         return fmt.Sprintf("<replace(%v)>", r.v)
364 }