]> Cypherpunks.ru repositories - gostls13.git/commitdiff
testing/slogtest: tests for slog handlers
authorJonathan Amsterdam <jba@google.com>
Sun, 23 Apr 2023 15:23:53 +0000 (11:23 -0400)
committerJonathan Amsterdam <jba@google.com>
Mon, 24 Apr 2023 18:07:26 +0000 (18:07 +0000)
Add a package for testing that a slog.Handler implementation
satisfies that interface's documented requirements.

Code copied from x/exp/slog/slogtest.

Updates #56345.

Change-Id: I89e94d93bfbe58e3c524758f7ac3c3fba2a2ea96
Reviewed-on: https://go-review.googlesource.com/c/go/+/487895
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
api/next/56345.txt
src/go/build/deps_test.go
src/testing/slogtest/example_test.go [new file with mode: 0644]
src/testing/slogtest/slogtest.go [new file with mode: 0644]

index 7a8f8254b957f695e014884266f80d2e7d0a33e9..fd3893e81d7a6963a9c713450f2e17154d90c5dd 100644 (file)
@@ -112,7 +112,6 @@ pkg log/slog, method (Level) Level() Level #56345
 pkg log/slog, method (Level) MarshalJSON() ([]uint8, error) #56345
 pkg log/slog, method (Level) MarshalText() ([]uint8, error) #56345
 pkg log/slog, method (Level) String() string #56345
-pkg log/slog, method (Record) Attrs(func(Attr)) #56345
 pkg log/slog, method (Record) Clone() Record #56345
 pkg log/slog, method (Record) NumAttrs() int #56345
 pkg log/slog, method (Value) Any() interface{} #56345
@@ -156,3 +155,4 @@ pkg log/slog, type Record struct, PC uintptr #56345
 pkg log/slog, type Record struct, Time time.Time #56345
 pkg log/slog, type TextHandler struct #56345
 pkg log/slog, type Value struct #56345
+pkg testing/slogtest, func TestHandler(slog.Handler, func() []map[string]interface{}) error #56345
index c0f3cfd780f8cf305b72de72c20e515eb225b5b4..0ac494f5b5d5518a84039449f849201631dccc74 100644 (file)
@@ -562,6 +562,9 @@ var depsRules = `
        < testing/iotest
        < testing/fstest;
 
+       log/slog
+       < testing/slogtest;
+
        FMT, flag, math/rand
        < testing/quick;
 
diff --git a/src/testing/slogtest/example_test.go b/src/testing/slogtest/example_test.go
new file mode 100644 (file)
index 0000000..61e4b46
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package slogtest_test
+
+import (
+       "bytes"
+       "encoding/json"
+       "log"
+       "log/slog"
+       "testing/slogtest"
+)
+
+// This example demonstrates one technique for testing a handler with this
+// package. The handler is given a [bytes.Buffer] to write to, and each line
+// of the resulting output is parsed.
+// For JSON output, [encoding/json.Unmarshal] produces a result in the desired
+// format when given a pointer to a map[string]any.
+func Example_parsing() {
+       var buf bytes.Buffer
+       h := slog.NewJSONHandler(&buf)
+
+       results := func() []map[string]any {
+               var ms []map[string]any
+               for _, line := range bytes.Split(buf.Bytes(), []byte{'\n'}) {
+                       if len(line) == 0 {
+                               continue
+                       }
+                       var m map[string]any
+                       if err := json.Unmarshal(line, &m); err != nil {
+                               panic(err) // In a real test, use t.Fatal.
+                       }
+                       ms = append(ms, m)
+               }
+               return ms
+       }
+       err := slogtest.TestHandler(h, results)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       // Output:
+}
diff --git a/src/testing/slogtest/slogtest.go b/src/testing/slogtest/slogtest.go
new file mode 100644 (file)
index 0000000..71076e5
--- /dev/null
@@ -0,0 +1,308 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package slogtest implements support for testing implementations of log/slog.Handler.
+package slogtest
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "log/slog"
+       "reflect"
+       "runtime"
+       "time"
+)
+
+type testCase struct {
+       // If non-empty, explanation explains the violated constraint.
+       explanation string
+       // f executes a single log event using its argument logger.
+       // So that mkdescs.sh can generate the right description,
+       // the body of f must appear on a single line whose first
+       // non-whitespace characters are "l.".
+       f func(*slog.Logger)
+       // If mod is not nil, it is called to modify the Record
+       // generated by the Logger before it is passed to the Handler.
+       mod func(*slog.Record)
+       // checks is a list of checks to run on the result.
+       checks []check
+}
+
+// TestHandler tests a [slog.Handler].
+// If TestHandler finds any misbehaviors, it returns an error for each,
+// combined into a single error with errors.Join.
+//
+// TestHandler installs the given Handler in a [slog.Logger] and
+// makes several calls to the Logger's output methods.
+//
+// The results function is invoked after all such calls.
+// It should return a slice of map[string]any, one for each call to a Logger output method.
+// The keys and values of the map should correspond to the keys and values of the Handler's
+// output. Each group in the output should be represented as its own nested map[string]any.
+// The standard keys slog.TimeKey, slog.LevelKey and slog.MessageKey should be used.
+//
+// If the Handler outputs JSON, then calling [encoding/json.Unmarshal] with a `map[string]any`
+// will create the right data structure.
+//
+// If a Handler intentionally drops an attribute that is checked by a test,
+// then the results function should check for its absence and add it to the map it returns.
+func TestHandler(h slog.Handler, results func() []map[string]any) error {
+       cases := []testCase{
+               {
+                       explanation: withSource("this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey"),
+                       f: func(l *slog.Logger) {
+                               l.Info("message")
+                       },
+                       checks: []check{
+                               hasKey(slog.TimeKey),
+                               hasKey(slog.LevelKey),
+                               hasAttr(slog.MessageKey, "message"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should output attributes passed to the logging function"),
+                       f: func(l *slog.Logger) {
+                               l.Info("message", "k", "v")
+                       },
+                       checks: []check{
+                               hasAttr("k", "v"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should ignore an empty Attr"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "a", "b", "", nil, "c", "d")
+                       },
+                       checks: []check{
+                               hasAttr("a", "b"),
+                               missingKey(""),
+                               hasAttr("c", "d"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should ignore a zero Record.Time"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "k", "v")
+                       },
+                       mod: func(r *slog.Record) { r.Time = time.Time{} },
+                       checks: []check{
+                               missingKey(slog.TimeKey),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should include the attributes from the WithAttrs method"),
+                       f: func(l *slog.Logger) {
+                               l.With("a", "b").Info("msg", "k", "v")
+                       },
+                       checks: []check{
+                               hasAttr("a", "b"),
+                               hasAttr("k", "v"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should handle Group attributes"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "a", "b", slog.Group("G", slog.String("c", "d")), "e", "f")
+                       },
+                       checks: []check{
+                               hasAttr("a", "b"),
+                               inGroup("G", hasAttr("c", "d")),
+                               hasAttr("e", "f"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should ignore an empty group"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "a", "b", slog.Group("G"), "e", "f")
+                       },
+                       checks: []check{
+                               hasAttr("a", "b"),
+                               missingKey("G"),
+                               hasAttr("e", "f"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should inline the Attrs of a group with an empty key"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f")
+
+                       },
+                       checks: []check{
+                               hasAttr("a", "b"),
+                               hasAttr("c", "d"),
+                               hasAttr("e", "f"),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should handle the WithGroup method"),
+                       f: func(l *slog.Logger) {
+                               l.WithGroup("G").Info("msg", "a", "b")
+                       },
+                       checks: []check{
+                               hasKey(slog.TimeKey),
+                               hasKey(slog.LevelKey),
+                               hasAttr(slog.MessageKey, "msg"),
+                               missingKey("a"),
+                               inGroup("G", hasAttr("a", "b")),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should handle multiple WithGroup and WithAttr calls"),
+                       f: func(l *slog.Logger) {
+                               l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg", "e", "f")
+                       },
+                       checks: []check{
+                               hasKey(slog.TimeKey),
+                               hasKey(slog.LevelKey),
+                               hasAttr(slog.MessageKey, "msg"),
+                               hasAttr("a", "b"),
+                               inGroup("G", hasAttr("c", "d")),
+                               inGroup("G", inGroup("H", hasAttr("e", "f"))),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should call Resolve on attribute values"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg", "k", &replace{"replaced"})
+                       },
+                       checks: []check{hasAttr("k", "replaced")},
+               },
+               {
+                       explanation: withSource("a Handler should call Resolve on attribute values in groups"),
+                       f: func(l *slog.Logger) {
+                               l.Info("msg",
+                                       slog.Group("G",
+                                               slog.String("a", "v1"),
+                                               slog.Any("b", &replace{"v2"})))
+                       },
+                       checks: []check{
+                               inGroup("G", hasAttr("a", "v1")),
+                               inGroup("G", hasAttr("b", "v2")),
+                       },
+               },
+               {
+                       explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"),
+                       f: func(l *slog.Logger) {
+                               l = l.With("k", &replace{"replaced"})
+                               l.Info("msg")
+                       },
+                       checks: []check{hasAttr("k", "replaced")},
+               },
+               {
+                       explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"),
+                       f: func(l *slog.Logger) {
+                               l = l.With(slog.Group("G",
+                                       slog.String("a", "v1"),
+                                       slog.Any("b", &replace{"v2"})))
+                               l.Info("msg")
+                       },
+                       checks: []check{
+                               inGroup("G", hasAttr("a", "v1")),
+                               inGroup("G", hasAttr("b", "v2")),
+                       },
+               },
+       }
+
+       // Run the handler on the test cases.
+       for _, c := range cases {
+               ht := h
+               if c.mod != nil {
+                       ht = &wrapper{h, c.mod}
+               }
+               l := slog.New(ht)
+               c.f(l)
+       }
+
+       // Collect and check the results.
+       var errs []error
+       res := results()
+       if g, w := len(res), len(cases); g != w {
+               return fmt.Errorf("got %d results, want %d", g, w)
+       }
+       for i, got := range results() {
+               c := cases[i]
+               for _, check := range c.checks {
+                       if p := check(got); p != "" {
+                               errs = append(errs, fmt.Errorf("%s: %s", p, c.explanation))
+                       }
+               }
+       }
+       return errors.Join(errs...)
+}
+
+type check func(map[string]any) string
+
+func hasKey(key string) check {
+       return func(m map[string]any) string {
+               if _, ok := m[key]; !ok {
+                       return fmt.Sprintf("missing key %q", key)
+               }
+               return ""
+       }
+}
+
+func missingKey(key string) check {
+       return func(m map[string]any) string {
+               if _, ok := m[key]; ok {
+                       return fmt.Sprintf("unexpected key %q", key)
+               }
+               return ""
+       }
+}
+
+func hasAttr(key string, wantVal any) check {
+       return func(m map[string]any) string {
+               if s := hasKey(key)(m); s != "" {
+                       return s
+               }
+               gotVal := m[key]
+               if !reflect.DeepEqual(gotVal, wantVal) {
+                       return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal)
+               }
+               return ""
+       }
+}
+
+func inGroup(name string, c check) check {
+       return func(m map[string]any) string {
+               v, ok := m[name]
+               if !ok {
+                       return fmt.Sprintf("missing group %q", name)
+               }
+               g, ok := v.(map[string]any)
+               if !ok {
+                       return fmt.Sprintf("value for group %q is not map[string]any", name)
+               }
+               return c(g)
+       }
+}
+
+type wrapper struct {
+       slog.Handler
+       mod func(*slog.Record)
+}
+
+func (h *wrapper) Handle(ctx context.Context, r slog.Record) error {
+       h.mod(&r)
+       return h.Handler.Handle(ctx, r)
+}
+
+func withSource(s string) string {
+       _, file, line, ok := runtime.Caller(1)
+       if !ok {
+               panic("runtime.Caller failed")
+       }
+       return fmt.Sprintf("%s (%s:%d)", s, file, line)
+}
+
+type replace struct {
+       v any
+}
+
+func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) }
+
+func (r *replace) String() string {
+       return fmt.Sprintf("<replace(%v)>", r.v)
+}