// 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 ( ) type testCase struct { // Subtest name. name string // 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 } var cases = []testCase{ { name: "built-ins", explanation: withSource("this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey"), f: func( *slog.Logger) { .Info("message") }, checks: []check{ hasKey(slog.TimeKey), hasKey(slog.LevelKey), hasAttr(slog.MessageKey, "message"), }, }, { name: "attrs", explanation: withSource("a Handler should output attributes passed to the logging function"), f: func( *slog.Logger) { .Info("message", "k", "v") }, checks: []check{ hasAttr("k", "v"), }, }, { name: "empty-attr", explanation: withSource("a Handler should ignore an empty Attr"), f: func( *slog.Logger) { .Info("msg", "a", "b", "", nil, "c", "d") }, checks: []check{ hasAttr("a", "b"), missingKey(""), hasAttr("c", "d"), }, }, { name: "zero-time", explanation: withSource("a Handler should ignore a zero Record.Time"), f: func( *slog.Logger) { .Info("msg", "k", "v") }, mod: func( *slog.Record) { .Time = time.Time{} }, checks: []check{ missingKey(slog.TimeKey), }, }, { name: "WithAttrs", explanation: withSource("a Handler should include the attributes from the WithAttrs method"), f: func( *slog.Logger) { .With("a", "b").Info("msg", "k", "v") }, checks: []check{ hasAttr("a", "b"), hasAttr("k", "v"), }, }, { name: "groups", explanation: withSource("a Handler should handle Group attributes"), f: func( *slog.Logger) { .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"), }, }, { name: "empty-group", explanation: withSource("a Handler should ignore an empty group"), f: func( *slog.Logger) { .Info("msg", "a", "b", slog.Group("G"), "e", "f") }, checks: []check{ hasAttr("a", "b"), missingKey("G"), hasAttr("e", "f"), }, }, { name: "inline-group", explanation: withSource("a Handler should inline the Attrs of a group with an empty key"), f: func( *slog.Logger) { .Info("msg", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f") }, checks: []check{ hasAttr("a", "b"), hasAttr("c", "d"), hasAttr("e", "f"), }, }, { name: "WithGroup", explanation: withSource("a Handler should handle the WithGroup method"), f: func( *slog.Logger) { .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")), }, }, { name: "multi-With", explanation: withSource("a Handler should handle multiple WithGroup and WithAttr calls"), f: func( *slog.Logger) { .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"))), }, }, { name: "empty-group-record", explanation: withSource("a Handler should not output groups if there are no attributes"), f: func( *slog.Logger) { .With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg") }, checks: []check{ hasKey(slog.TimeKey), hasKey(slog.LevelKey), hasAttr(slog.MessageKey, "msg"), hasAttr("a", "b"), inGroup("G", hasAttr("c", "d")), inGroup("G", missingKey("H")), }, }, { name: "resolve", explanation: withSource("a Handler should call Resolve on attribute values"), f: func( *slog.Logger) { .Info("msg", "k", &replace{"replaced"}) }, checks: []check{hasAttr("k", "replaced")}, }, { name: "resolve-groups", explanation: withSource("a Handler should call Resolve on attribute values in groups"), f: func( *slog.Logger) { .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")), }, }, { name: "resolve-WithAttrs", explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"), f: func( *slog.Logger) { = .With("k", &replace{"replaced"}) .Info("msg") }, checks: []check{hasAttr("k", "replaced")}, }, { name: "resolve-WithAttrs-groups", explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"), f: func( *slog.Logger) { = .With(slog.Group("G", slog.String("a", "v1"), slog.Any("b", &replace{"v2"}))) .Info("msg") }, checks: []check{ inGroup("G", hasAttr("a", "v1")), inGroup("G", hasAttr("b", "v2")), }, }, { name: "empty-PC", explanation: withSource("a Handler should not output SourceKey if the PC is zero"), f: func( *slog.Logger) { .Info("message") }, mod: func( *slog.Record) { .PC = 0 }, checks: []check{ missingKey(slog.SourceKey), }, }, } // 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 Handler should be enabled for levels Info and above. // // 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 ( slog.Handler, func() []map[string]any) error { // Run the handler on the test cases. for , := range cases { := if .mod != nil { = &wrapper{, .mod} } := slog.New() .f() } // Collect and check the results. var []error := () if , := len(), len(cases); != { return fmt.Errorf("got %d results, want %d", , ) } for , := range () { := cases[] for , := range .checks { if := (); != "" { = append(, fmt.Errorf("%s: %s", , .explanation)) } } } return errors.Join(...) } // Run exercises a [slog.Handler] on the same test cases as [TestHandler], but // runs each case in a subtest. For each test case, it first calls newHandler to // get an instance of the handler under test, then runs the test case, then // calls result to get the result. If the test case fails, it calls t.Error. func ( *testing.T, func(*testing.T) slog.Handler, func(*testing.T) map[string]any) { for , := range cases { .Run(.name, func( *testing.T) { := () if .mod != nil { = &wrapper{, .mod} } := slog.New() .f() := () for , := range .checks { if := (); != "" { .Errorf("%s: %s", , .explanation) } } }) } } type check func(map[string]any) string func hasKey( string) check { return func( map[string]any) string { if , := []; ! { return fmt.Sprintf("missing key %q", ) } return "" } } func missingKey( string) check { return func( map[string]any) string { if , := []; { return fmt.Sprintf("unexpected key %q", ) } return "" } } func hasAttr( string, any) check { return func( map[string]any) string { if := hasKey()(); != "" { return } := [] if !reflect.DeepEqual(, ) { return fmt.Sprintf("%q: got %#v, want %#v", , , ) } return "" } } func inGroup( string, check) check { return func( map[string]any) string { , := [] if ! { return fmt.Sprintf("missing group %q", ) } , := .(map[string]any) if ! { return fmt.Sprintf("value for group %q is not map[string]any", ) } return () } } type wrapper struct { slog.Handler mod func(*slog.Record) } func ( *wrapper) ( context.Context, slog.Record) error { .mod(&) return .Handler.Handle(, ) } func withSource( string) string { , , , := runtime.Caller(1) if ! { panic("runtime.Caller failed") } return fmt.Sprintf("%s (%s:%d)", , , ) } type replace struct { v any } func ( *replace) () slog.Value { return slog.AnyValue(.v) } func ( *replace) () string { return fmt.Sprintf("<replace(%v)>", .v) }