What does testing/slogtest.TestHandler check that your own handler tests usually miss?
answer
- a contract test shipped with the contract
- you supply the parser, it supplies the cases
- most rules are about what must NOT appear
- groups come back as nested maps
- empty group silent, empty key inlined
basics
~20 stesting/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 linesfunc 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
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.
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.
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.
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