diff options
author | Jonathan Amsterdam <jba@google.com> | 2023-04-23 11:23:53 -0400 |
---|---|---|
committer | Jonathan Amsterdam <jba@google.com> | 2023-04-24 18:07:26 +0000 |
commit | c80cedec9378b0a1cd5cb400e179bb25e2e7f810 (patch) | |
tree | fd0da6d5813983dd701d6b1a359d61718548e811 /src/testing | |
parent | ddd822e5ca0e686ae64d921bcfe28119c34278fb (diff) | |
download | go-git-c80cedec9378b0a1cd5cb400e179bb25e2e7f810.tar.gz |
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 #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>
Diffstat (limited to 'src/testing')
-rw-r--r-- | src/testing/slogtest/example_test.go | 44 | ||||
-rw-r--r-- | src/testing/slogtest/slogtest.go | 308 |
2 files changed, 352 insertions, 0 deletions
diff --git a/src/testing/slogtest/example_test.go b/src/testing/slogtest/example_test.go new file mode 100644 index 0000000000..61e4b46e12 --- /dev/null +++ b/src/testing/slogtest/example_test.go @@ -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 index 0000000000..71076e52f4 --- /dev/null +++ b/src/testing/slogtest/slogtest.go @@ -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) +} |