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)
 	}