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  }