slog-handler-guide: add testing section View at https://github.com/jba/markdown#testing. Change-Id: I0d8996395b03c3d1c7b7e50cce323a331fd614e1 Reviewed-on: https://go-review.googlesource.com/c/example/+/513137 TryBot-Result: Gopher Robot <gobot@golang.org> Reviewed-by: Ian Cottrell <iancottrell@google.com> Run-TryBot: Jonathan Amsterdam <jba@google.com>
diff --git a/go.mod b/go.mod index 80b7ee1..1996732 100644 --- a/go.mod +++ b/go.mod
@@ -3,3 +3,5 @@ go 1.18 require golang.org/x/tools v0.0.0-20210112183307-1e6ecd4bf1b0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum index 18a9ad7..114d0dc 100644 --- a/go.sum +++ b/go.sum
@@ -22,3 +22,6 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/slog-handler-guide/README.md b/slog-handler-guide/README.md index 45d0f8b..0dbb340 100644 --- a/slog-handler-guide/README.md +++ b/slog-handler-guide/README.md
@@ -706,15 +706,68 @@ ## Testing +The [`Handler` contract](https://pkg.go.dev/log/slog#Handler) specifies several +constraints on handlers. To verify that your handler follows these rules and generally produces proper -output, use the [testing/slogtest package](https://pkg.go.dev/log/slog). +output, use the [testing/slogtest package](https://pkg.go.dev/testing/slogtest). -TODO(jba): show the test function. +That package's `TestHandler` function takes an instance of your handler and +a function that returns its output formatted as a slice of maps. Here is the test function +for our example handler: -TODO(jba): reintroduce the material on Record.Clone that used to be here. +``` +func TestSlogtest(t *testing.T) { + var buf bytes.Buffer + err := slogtest.TestHandler(New(&buf, nil), func() []map[string]any { + return parseLogEntries(t, buf.Bytes()) + }) + if err != nil { + t.Error(err) + } +} +``` + +Calling `TestHandler` is easy. The hard part is parsing the output. +`TestHandler` calls your handler multiple times, resulting in a sequence of log +entries. +It is your job to parse each entry into a `map[string]any`. +A group in an entry should appear as a nested map. + +If your handler outputs a standard format, you can use an existing parser. +For example, if your handler outputs one JSON object per line, then you +can split the output into lines and call `encoding/json.Unmarshal` on each. +Parsers for other formats that can unmarshal into a map can be used out +of the box. +Our example output is enough like YAML so that we can use the `gopkg.in/yaml.v3` +package to parse it: + +``` +func parseLogEntries(t *testing.T, data []byte) []map[string]any { + entries := bytes.Split(data, []byte("---\n")) + entries = entries[:len(entries)-1] // last one is empty + var ms []map[string]any + for _, e := range entries { + var m map[string]any + if err := yaml.Unmarshal([]byte(e), &m); err != nil { + t.Fatal(err) + } + ms = append(ms, m) + } + return ms +} +``` + +If you have to write your own parser, it can be far from perfect. +The `slogtest` package uses only a handful of simple attributes. +(It is testing handler conformance, not parsing.) +Your parser can ignore edge cases like whitespace and newlines in keys and +values. Before switching to a YAML parser, we wrote an adequate custom parser +in 65 lines. # General considerations +TODO(jba): reintroduce the material on Record.Clone that used to be here. + ## Concurrency safety A handler must work properly when a single `Logger` is shared among several
diff --git a/slog-handler-guide/guide.md b/slog-handler-guide/guide.md index 90554f5..578236b 100644 --- a/slog-handler-guide/guide.md +++ b/slog-handler-guide/guide.md
@@ -449,15 +449,44 @@ ## Testing +The [`Handler` contract](https://pkg.go.dev/log/slog#Handler) specifies several +constraints on handlers. To verify that your handler follows these rules and generally produces proper -output, use the [testing/slogtest package](https://pkg.go.dev/log/slog). +output, use the [testing/slogtest package](https://pkg.go.dev/testing/slogtest). -TODO(jba): show the test function. +That package's `TestHandler` function takes an instance of your handler and +a function that returns its output formatted as a slice of maps. Here is the test function +for our example handler: -TODO(jba): reintroduce the material on Record.Clone that used to be here. +%include indenthandler3/indent_handler_test.go TestSlogtest - + +Calling `TestHandler` is easy. The hard part is parsing the output. +`TestHandler` calls your handler multiple times, resulting in a sequence of log +entries. +It is your job to parse each entry into a `map[string]any`. +A group in an entry should appear as a nested map. + +If your handler outputs a standard format, you can use an existing parser. +For example, if your handler outputs one JSON object per line, then you +can split the output into lines and call `encoding/json.Unmarshal` on each. +Parsers for other formats that can unmarshal into a map can be used out +of the box. +Our example output is enough like YAML so that we can use the `gopkg.in/yaml.v3` +package to parse it: + +%include indenthandler3/indent_handler_test.go parseLogEntries - + +If you have to write your own parser, it can be far from perfect. +The `slogtest` package uses only a handful of simple attributes. +(It is testing handler conformance, not parsing.) +Your parser can ignore edge cases like whitespace and newlines in keys and +values. Before switching to a YAML parser, we wrote an adequate custom parser +in 65 lines. # General considerations +TODO(jba): reintroduce the material on Record.Clone that used to be here. + ## Concurrency safety A handler must work properly when a single `Logger` is shared among several
diff --git a/slog-handler-guide/indenthandler3/indent_handler_test.go b/slog-handler-guide/indenthandler3/indent_handler_test.go index f67bd03..8f6e99c 100644 --- a/slog-handler-guide/indenthandler3/indent_handler_test.go +++ b/slog-handler-guide/indenthandler3/indent_handler_test.go
@@ -3,30 +3,29 @@ package indenthandler import ( - "bufio" "bytes" - "fmt" + "log/slog" "reflect" "regexp" - "strconv" - "strings" "testing" "testing/slogtest" - "unicode" - "log/slog" + "gopkg.in/yaml.v3" ) +// !+TestSlogtest func TestSlogtest(t *testing.T) { var buf bytes.Buffer err := slogtest.TestHandler(New(&buf, nil), func() []map[string]any { - return parseLogEntries(buf.String()) + return parseLogEntries(t, buf.Bytes()) }) if err != nil { t.Error(err) } } +// !-TestSlogtest + func Test(t *testing.T) { var buf bytes.Buffer l := slog.New(New(&buf, nil)) @@ -56,71 +55,22 @@ } } -func parseLogEntries(s string) []map[string]any { +// !+parseLogEntries +func parseLogEntries(t *testing.T, data []byte) []map[string]any { + entries := bytes.Split(data, []byte("---\n")) + entries = entries[:len(entries)-1] // last one is empty var ms []map[string]any - scan := bufio.NewScanner(strings.NewReader(s)) - for scan.Scan() { - m := parseGroup(scan) + for _, e := range entries { + var m map[string]any + if err := yaml.Unmarshal([]byte(e), &m); err != nil { + t.Fatal(err) + } ms = append(ms, m) } - if scan.Err() != nil { - panic(scan.Err()) - } return ms } -func parseGroup(scan *bufio.Scanner) map[string]any { - m := map[string]any{} - groupIndent := -1 - for { - line := scan.Text() - if line == "---" { // end of entry - break - } - k, v, found := strings.Cut(line, ":") - if !found { - panic(fmt.Sprintf("no ':' in line %q", line)) - } - indent := strings.IndexFunc(k, func(r rune) bool { - return !unicode.IsSpace(r) - }) - if indent < 0 { - panic("blank line") - } - if groupIndent < 0 { - // First line in group; remember the indent. - groupIndent = indent - } else if indent < groupIndent { - // End of group - break - } else if indent > groupIndent { - panic(fmt.Sprintf("indent increased on line %q", line)) - } - - key := strings.TrimSpace(k) - if v == "" { - // Just a key: start of a group. - if !scan.Scan() { - panic("empty group") - } - m[key] = parseGroup(scan) - } else { - v = strings.TrimSpace(v) - if len(v) > 0 && v[0] == '"' { - var err error - v, err = strconv.Unquote(v) - if err != nil { - panic(err) - } - } - m[key] = v - if !scan.Scan() { - break - } - } - } - return m -} +// !-parseLogEntries func TestParseLogEntries(t *testing.T) { in := ` @@ -129,7 +79,7 @@ c: 3 g: h: 4 - i: 5 + i: five d: 6 --- e: 7 @@ -137,20 +87,20 @@ ` want := []map[string]any{ { - "a": "1", - "b": "2", - "c": "3", + "a": 1, + "b": 2, + "c": 3, "g": map[string]any{ - "h": "4", - "i": "5", + "h": 4, + "i": "five", }, - "d": "6", + "d": 6, }, { - "e": "7", + "e": 7, }, } - got := parseLogEntries(in[1:]) + got := parseLogEntries(t, []byte(in[1:])) if !reflect.DeepEqual(got, want) { t.Errorf("\ngot:\n%v\nwant:\n%v", got, want) }