1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303
|
package slogtest
import (
"context"
"fmt"
"reflect"
"runtime"
"time"
"golang.org/x/exp/slog"
)
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 errorsJoin(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)
}
|