Skip to content

Commit 7caef55

Browse files
jbaeric
authored andcommitted
testing/slogtest: tests for slog handlers
Add a package for testing that a slog.Handler implementation satisfies that interface's documented requirements. Code copied from x/exp/slog/slogtest. Updates golang#56345. Change-Id: I89e94d93bfbe58e3c524758f7ac3c3fba2a2ea96 Reviewed-on: https://go-review.googlesource.com/c/go/+/487895 TryBot-Result: Gopher Robot <[email protected]> Run-TryBot: Jonathan Amsterdam <[email protected]> Reviewed-by: Alan Donovan <[email protected]>
1 parent 0233c66 commit 7caef55

File tree

4 files changed

+356
-1
lines changed

4 files changed

+356
-1
lines changed

Diff for: api/next/56345.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ pkg log/slog, method (Level) Level() Level #56345
112112
pkg log/slog, method (Level) MarshalJSON() ([]uint8, error) #56345
113113
pkg log/slog, method (Level) MarshalText() ([]uint8, error) #56345
114114
pkg log/slog, method (Level) String() string #56345
115-
pkg log/slog, method (Record) Attrs(func(Attr)) #56345
116115
pkg log/slog, method (Record) Clone() Record #56345
117116
pkg log/slog, method (Record) NumAttrs() int #56345
118117
pkg log/slog, method (Value) Any() interface{} #56345
@@ -156,3 +155,4 @@ pkg log/slog, type Record struct, PC uintptr #56345
156155
pkg log/slog, type Record struct, Time time.Time #56345
157156
pkg log/slog, type TextHandler struct #56345
158157
pkg log/slog, type Value struct #56345
158+
pkg testing/slogtest, func TestHandler(slog.Handler, func() []map[string]interface{}) error #56345

Diff for: src/go/build/deps_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,9 @@ var depsRules = `
552552
< testing/iotest
553553
< testing/fstest;
554554
555+
log/slog
556+
< testing/slogtest;
557+
555558
FMT, flag, math/rand
556559
< testing/quick;
557560

Diff for: src/testing/slogtest/example_test.go

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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_test
6+
7+
import (
8+
"bytes"
9+
"encoding/json"
10+
"log"
11+
"log/slog"
12+
"testing/slogtest"
13+
)
14+
15+
// This example demonstrates one technique for testing a handler with this
16+
// package. The handler is given a [bytes.Buffer] to write to, and each line
17+
// of the resulting output is parsed.
18+
// For JSON output, [encoding/json.Unmarshal] produces a result in the desired
19+
// format when given a pointer to a map[string]any.
20+
func Example_parsing() {
21+
var buf bytes.Buffer
22+
h := slog.NewJSONHandler(&buf)
23+
24+
results := func() []map[string]any {
25+
var ms []map[string]any
26+
for _, line := range bytes.Split(buf.Bytes(), []byte{'\n'}) {
27+
if len(line) == 0 {
28+
continue
29+
}
30+
var m map[string]any
31+
if err := json.Unmarshal(line, &m); err != nil {
32+
panic(err) // In a real test, use t.Fatal.
33+
}
34+
ms = append(ms, m)
35+
}
36+
return ms
37+
}
38+
err := slogtest.TestHandler(h, results)
39+
if err != nil {
40+
log.Fatal(err)
41+
}
42+
43+
// Output:
44+
}

Diff for: src/testing/slogtest/slogtest.go

+308
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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+
}

0 commit comments

Comments
 (0)