skip to content

What does testing/slogtest.TestHandler check that your own handler tests usually miss?

level: middleimportance: should knowfreq 33%

answer

  1. a contract test shipped with the contract
  2. you supply the parser, it supplies the cases
  3. most rules are about what must NOT appear
  4. groups come back as nested maps
  5. empty group silent, empty key inlined

basics

~20 s

testing/slogtest.TestHandler drives a handler through the awkward corners of the slog.Handler contract - a zero record time, an empty attribute, a group with no attributes, a group with an empty key - and checks the output you parse back.

solid answer

~50 s

`slogtest.TestHandler(h, results)` runs a fixed suite of records through your handler, then calls the `results` function you supply, which returns one `map[string]any` per record produced — for a line-framed handler you parse each output line back into a map, with nested maps for groups. It checks the parts of the contract that only surface at the edges: a zero `Time` must produce no time field; an attribute whose key and value are both zero is ignored; a group with no attributes emits nothing even though it has a name; a group with an empty key is inlined rather than nested; `WithAttrs` and `WithGroup` compose in the right order; the standard `time`, `level` and `msg` keys are present. It returns a joined error listing every case that failed. What it does not check is concurrency, allocation cost, your level filtering, or your exact byte format — those still need your own tests.

code

go · 19 lines
go
func TestHandlerContract(t *testing.T) {
	var buf bytes.Buffer
	h := New(&buf) // our handler, one JSON object per line

	err := slogtest.TestHandler(h, func() []map[string]any {
		var got []map[string]any
		for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) {
			var m map[string]any
			if err := json.Unmarshal(line, &m); err != nil {
				t.Fatalf("bad output line %q: %v", line, err)
			}
			got = append(got, m)
		}
		return got
	})
	if err != nil {
		t.Error(err)
	}
}

go deeper

for a junior

Know that the standard library ships a test for handler implementations and that you supply a function returning what your handler produced, one map per record.

for a middle

Be ready to name several rules it enforces — no field for a zero time, nothing for an empty group, inlining for an empty group key — and to describe wiring it to a bytes.Buffer sink.

for a senior

Say what it leaves uncovered: concurrency, level filtering, allocation cost and your own format, and explain why a shared handler keeps this running in CI rather than as a one-off check.

for a principal

Argue for contract tests at package boundaries generally: the value is that a later optimisation inside the handler cannot quietly change what importing teams' log pipelines receive.

## The problem it solves A custom `slog.Handler` looks easy to test: log a couple of records, assert the output matches. That test passes on the day you write it and says nothing about whether your handler is a well-behaved implementation of the interface. The `slog.Handler` documentation contains a list of obligations that only appear in situations you would never think to construct by hand — and if you miss one, the handler misbehaves only when it is used by *other* people's code, most often when someone builds a logger with groups. `testing/slogtest` exists to close that gap. It is a contract test for the interface, shipped with the interface. ## The API Two entry points: - `slogtest.TestHandler(h slog.Handler, results func() []map[string]any) error` — runs every case against the single handler you pass, then calls `results` once to collect what was produced, in order, and returns a joined error describing every failure. - `slogtest.Run(t *testing.T, newHandler func(*testing.T) slog.Handler, result func(*testing.T) map[string]any)` — the same suite, but it builds a fresh handler per case and reports each case as its own subtest, so a failure names the rule you broke. Both need you to bridge from your output format back to a generic form. The framework has no idea whether you write JSON, logfmt or a binary frame; you tell it how to read your own output. For a handler that writes one JSON object per line, `results` splits the accumulated bytes on newlines and unmarshals each into a `map[string]any`. Groups must come back as *nested* maps, because that is how the checker expresses a group. ## The rules it actually enforces - **Zero time.** A record with a zero `Time` must not produce a time field at all. Handlers that unconditionally format `r.Time` print a year-1 timestamp instead. - **Empty attribute.** An attribute whose key and value are both zero — `slog.Attr{}` — is dropped. - **Empty group.** A group with a name but no attributes produces nothing. Handlers that open a brace on entry and close it on exit emit an empty object here. - **Inline group.** A group whose key is the empty string has its attributes inlined at the current level rather than nested under `""`. - **Group composition.** Attributes added by `WithAttrs` after a `WithGroup` land inside that group; attributes on the record itself land inside the innermost group; nesting several groups nests the keys. - **Standard keys.** The built-in fields appear under the conventional `time`, `level` and `msg` keys, which is what makes output from different handlers interchangeable downstream. - **Resolution.** Attribute values are resolved before being written, so a value that knows how to represent itself gets the chance to. Almost every one of these is a rule about *absence* or *nesting*, which is exactly what an ad-hoc test never asserts. ## How to wire it up Give your handler a `bytes.Buffer` as its writer, run the suite, then parse the buffer in `results`. Keep the parsing strict — if a line fails to unmarshal, fail the test rather than skipping it, otherwise a malformed record silently reduces the number of results and the mismatch report becomes confusing. Running it in CI is the point. A handler published as a module that other teams import should have this as a permanent test, not a one-off check, because the rules are easy to break later when someone optimises the group handling. ## What it does not cover Being explicit about the gaps matters as much as the coverage: - **Concurrency.** Nothing in the suite calls `Handle` from several goroutines. Safety under concurrent use is a contract requirement the suite does not test; that needs your own `-race` test. - **Enabled.** Level filtering is your handler's own policy, so the suite does not exercise it. - **Derived-handler independence.** The suite composes `WithAttrs` and `WithGroup` in a chain, but it will not necessarily catch a handler whose two children corrupt each other's stored attributes through a shared backing array. - **Cost and format.** Allocation counts, field ordering, and the precise bytes you emit are yours to test. So `slogtest` is necessary and not sufficient: it proves you implement the interface as documented, and leaves the properties specific to *your* handler to you.

  • How do you supply the results function when your handler writes a line-framed format?
    Point the handler at a `bytes.Buffer`, then in `results` split the accumulated bytes on the line separator and decode each frame into a `map[string]any`, preserving groups as nested maps. Fail the test loudly on a line that does not parse — returning fewer maps than records makes the framework's mismatch report much harder to read.
  • What contract obligations does slogtest not verify?
    Concurrency safety, level filtering through Enabled, cost, and your exact output bytes. It also composes derivation in a single chain, so it will not reliably catch two sibling handlers corrupting each other's stored attributes. Treat it as necessary but not sufficient, and keep your own -race and format tests alongside it.
  • Why does the suite insist a group with no attributes produces no output?
    Because groups are structural, not data. A logger built with WithGroup that then logs a record with no attributes would otherwise emit an empty object on every line, and downstream consumers would see a field that exists sometimes and means nothing. Suppressing it keeps a handler's output stable no matter how many empty groups a caller layered on.

saying these in an interview costs you the question

  • Thinks slogtest checks concurrency safety
  • Emits an empty object for a group with no attributes
  • Formats a zero record time as a year-1 timestamp
  • Nests an empty-key group instead of inlining it
  • Believes the suite compares against golden output files