golang.org/x/exp@v0.0.0-20240506185415-9bf2ced13842/slog/slogtest/slogtest.go (about) 1 package slogtest 2 3 import ( 4 "context" 5 "fmt" 6 "reflect" 7 "runtime" 8 "time" 9 10 "golang.org/x/exp/slog" 11 ) 12 13 type testCase struct { 14 // If non-empty, explanation explains the violated constraint. 15 explanation string 16 // f executes a single log event using its argument logger. 17 // So that mkdescs.sh can generate the right description, 18 // the body of f must appear on a single line whose first 19 // non-whitespace characters are "l.". 20 f func(*slog.Logger) 21 // If mod is not nil, it is called to modify the Record 22 // generated by the Logger before it is passed to the Handler. 23 mod func(*slog.Record) 24 // checks is a list of checks to run on the result. 25 checks []check 26 } 27 28 // TestHandler tests a [slog.Handler]. 29 // If TestHandler finds any misbehaviors, it returns an error for each, 30 // combined into a single error with errors.Join. 31 // 32 // TestHandler installs the given Handler in a [slog.Logger] and 33 // makes several calls to the Logger's output methods. 34 // 35 // The results function is invoked after all such calls. 36 // It should return a slice of map[string]any, one for each call to a Logger output method. 37 // The keys and values of the map should correspond to the keys and values of the Handler's 38 // output. Each group in the output should be represented as its own nested map[string]any. 39 // The standard keys slog.TimeKey, slog.LevelKey and slog.MessageKey should be used. 40 // 41 // If the Handler outputs JSON, then calling [encoding/json.Unmarshal] with a `map[string]any` 42 // will create the right data structure. 43 // 44 // If a Handler intentionally drops an attribute that is checked by a test, 45 // then the results function should check for its absence and add it to the map it returns. 46 func TestHandler(h slog.Handler, results func() []map[string]any) error { 47 cases := []testCase{ 48 { 49 explanation: withSource("this test expects slog.TimeKey, slog.LevelKey and slog.MessageKey"), 50 f: func(l *slog.Logger) { 51 l.Info("message") 52 }, 53 checks: []check{ 54 hasKey(slog.TimeKey), 55 hasKey(slog.LevelKey), 56 hasAttr(slog.MessageKey, "message"), 57 }, 58 }, 59 { 60 explanation: withSource("a Handler should output attributes passed to the logging function"), 61 f: func(l *slog.Logger) { 62 l.Info("message", "k", "v") 63 }, 64 checks: []check{ 65 hasAttr("k", "v"), 66 }, 67 }, 68 { 69 explanation: withSource("a Handler should ignore an empty Attr"), 70 f: func(l *slog.Logger) { 71 l.Info("msg", "a", "b", "", nil, "c", "d") 72 }, 73 checks: []check{ 74 hasAttr("a", "b"), 75 missingKey(""), 76 hasAttr("c", "d"), 77 }, 78 }, 79 { 80 explanation: withSource("a Handler should ignore a zero Record.Time"), 81 f: func(l *slog.Logger) { 82 l.Info("msg", "k", "v") 83 }, 84 mod: func(r *slog.Record) { r.Time = time.Time{} }, 85 checks: []check{ 86 missingKey(slog.TimeKey), 87 }, 88 }, 89 { 90 explanation: withSource("a Handler should include the attributes from the WithAttrs method"), 91 f: func(l *slog.Logger) { 92 l.With("a", "b").Info("msg", "k", "v") 93 }, 94 checks: []check{ 95 hasAttr("a", "b"), 96 hasAttr("k", "v"), 97 }, 98 }, 99 { 100 explanation: withSource("a Handler should handle Group attributes"), 101 f: func(l *slog.Logger) { 102 l.Info("msg", "a", "b", slog.Group("G", slog.String("c", "d")), "e", "f") 103 }, 104 checks: []check{ 105 hasAttr("a", "b"), 106 inGroup("G", hasAttr("c", "d")), 107 hasAttr("e", "f"), 108 }, 109 }, 110 { 111 explanation: withSource("a Handler should ignore an empty group"), 112 f: func(l *slog.Logger) { 113 l.Info("msg", "a", "b", slog.Group("G"), "e", "f") 114 }, 115 checks: []check{ 116 hasAttr("a", "b"), 117 missingKey("G"), 118 hasAttr("e", "f"), 119 }, 120 }, 121 { 122 explanation: withSource("a Handler should inline the Attrs of a group with an empty key"), 123 f: func(l *slog.Logger) { 124 l.Info("msg", "a", "b", slog.Group("", slog.String("c", "d")), "e", "f") 125 126 }, 127 checks: []check{ 128 hasAttr("a", "b"), 129 hasAttr("c", "d"), 130 hasAttr("e", "f"), 131 }, 132 }, 133 { 134 explanation: withSource("a Handler should handle the WithGroup method"), 135 f: func(l *slog.Logger) { 136 l.WithGroup("G").Info("msg", "a", "b") 137 }, 138 checks: []check{ 139 hasKey(slog.TimeKey), 140 hasKey(slog.LevelKey), 141 hasAttr(slog.MessageKey, "msg"), 142 missingKey("a"), 143 inGroup("G", hasAttr("a", "b")), 144 }, 145 }, 146 { 147 explanation: withSource("a Handler should handle multiple WithGroup and WithAttr calls"), 148 f: func(l *slog.Logger) { 149 l.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg", "e", "f") 150 }, 151 checks: []check{ 152 hasKey(slog.TimeKey), 153 hasKey(slog.LevelKey), 154 hasAttr(slog.MessageKey, "msg"), 155 hasAttr("a", "b"), 156 inGroup("G", hasAttr("c", "d")), 157 inGroup("G", inGroup("H", hasAttr("e", "f"))), 158 }, 159 }, 160 { 161 explanation: withSource("a Handler should call Resolve on attribute values"), 162 f: func(l *slog.Logger) { 163 l.Info("msg", "k", &replace{"replaced"}) 164 }, 165 checks: []check{hasAttr("k", "replaced")}, 166 }, 167 { 168 explanation: withSource("a Handler should call Resolve on attribute values in groups"), 169 f: func(l *slog.Logger) { 170 l.Info("msg", 171 slog.Group("G", 172 slog.String("a", "v1"), 173 slog.Any("b", &replace{"v2"}))) 174 }, 175 checks: []check{ 176 inGroup("G", hasAttr("a", "v1")), 177 inGroup("G", hasAttr("b", "v2")), 178 }, 179 }, 180 { 181 explanation: withSource("a Handler should call Resolve on attribute values from WithAttrs"), 182 f: func(l *slog.Logger) { 183 l = l.With("k", &replace{"replaced"}) 184 l.Info("msg") 185 }, 186 checks: []check{hasAttr("k", "replaced")}, 187 }, 188 { 189 explanation: withSource("a Handler should call Resolve on attribute values in groups from WithAttrs"), 190 f: func(l *slog.Logger) { 191 l = l.With(slog.Group("G", 192 slog.String("a", "v1"), 193 slog.Any("b", &replace{"v2"}))) 194 l.Info("msg") 195 }, 196 checks: []check{ 197 inGroup("G", hasAttr("a", "v1")), 198 inGroup("G", hasAttr("b", "v2")), 199 }, 200 }, 201 } 202 203 // Run the handler on the test cases. 204 for _, c := range cases { 205 ht := h 206 if c.mod != nil { 207 ht = &wrapper{h, c.mod} 208 } 209 l := slog.New(ht) 210 c.f(l) 211 } 212 213 // Collect and check the results. 214 var errs []error 215 res := results() 216 if g, w := len(res), len(cases); g != w { 217 return fmt.Errorf("got %d results, want %d", g, w) 218 } 219 for i, got := range results() { 220 c := cases[i] 221 for _, check := range c.checks { 222 if p := check(got); p != "" { 223 errs = append(errs, fmt.Errorf("%s: %s", p, c.explanation)) 224 } 225 } 226 } 227 return errorsJoin(errs...) 228 } 229 230 type check func(map[string]any) string 231 232 func hasKey(key string) check { 233 return func(m map[string]any) string { 234 if _, ok := m[key]; !ok { 235 return fmt.Sprintf("missing key %q", key) 236 } 237 return "" 238 } 239 } 240 241 func missingKey(key string) check { 242 return func(m map[string]any) string { 243 if _, ok := m[key]; ok { 244 return fmt.Sprintf("unexpected key %q", key) 245 } 246 return "" 247 } 248 } 249 250 func hasAttr(key string, wantVal any) check { 251 return func(m map[string]any) string { 252 if s := hasKey(key)(m); s != "" { 253 return s 254 } 255 gotVal := m[key] 256 if !reflect.DeepEqual(gotVal, wantVal) { 257 return fmt.Sprintf("%q: got %#v, want %#v", key, gotVal, wantVal) 258 } 259 return "" 260 } 261 } 262 263 func inGroup(name string, c check) check { 264 return func(m map[string]any) string { 265 v, ok := m[name] 266 if !ok { 267 return fmt.Sprintf("missing group %q", name) 268 } 269 g, ok := v.(map[string]any) 270 if !ok { 271 return fmt.Sprintf("value for group %q is not map[string]any", name) 272 } 273 return c(g) 274 } 275 } 276 277 type wrapper struct { 278 slog.Handler 279 mod func(*slog.Record) 280 } 281 282 func (h *wrapper) Handle(ctx context.Context, r slog.Record) error { 283 h.mod(&r) 284 return h.Handler.Handle(ctx, r) 285 } 286 287 func withSource(s string) string { 288 _, file, line, ok := runtime.Caller(1) 289 if !ok { 290 panic("runtime.Caller failed") 291 } 292 return fmt.Sprintf("%s (%s:%d)", s, file, line) 293 } 294 295 type replace struct { 296 v any 297 } 298 299 func (r *replace) LogValue() slog.Value { return slog.AnyValue(r.v) } 300 301 func (r *replace) String() string { 302 return fmt.Sprintf("<replace(%v)>", r.v) 303 }