github.com/AndrienkoAleksandr/go@v0.0.19/src/testing/slogtest/slogtest.go (about)

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