An open API service indexing awesome lists of open source software.

https://github.com/rafaelespinoza/slogtesting

test structured logging with log/slog values
https://github.com/rafaelespinoza/slogtesting

slog slog-handler structured-logging testing

Last synced: 5 months ago
JSON representation

test structured logging with log/slog values

Awesome Lists containing this project

README

          

slogtesting
---

[![](https://pkg.go.dev/badge/github.com/rafaelespinoza/slogtesting)](https://pkg.go.dev/github.com/rafaelespinoza/slogtesting)
[![tests](https://github.com/rafaelespinoza/slogtesting/actions/workflows/tests.yaml/badge.svg)](https://github.com/rafaelespinoza/slogtesting/actions/workflows/tests.yaml)
[![codecov](https://codecov.io/gh/rafaelespinoza/slogtesting/graph/badge.svg?token=ZL2BP29ZVX)](https://codecov.io/gh/rafaelespinoza/slogtesting)

`slogtesting` is a golang library to test that your application's structured
logging outputs the intended data.
It requires the use of [log/slog](https://pkg.go.dev/log/slog).

![the golang gopher wearing a labcoat, inspecting a log](./.assets/gopher.png)

This library operates on the interface level and on the outputs. It has a
`slog.Handler` implementation (the backend) made to intercept messages from the
application's `*slog.Logger`(the frontend), and outputs golang values rather
than log entries in a particular format. When you're testing values at a higher
abstraction level, your tests do not need to be concerned with interpreting log
entries in their format.

## Why

This begs the question, why test logs in the first place?

* To check that sensitive information, such as PII or credentials, is redacted
or not logged at all. Example: the application is handling this kind of data,
and you need tests for this behavior.
* To check that a log is emitted with sufficient contextual data to be useful;
IDs, paths, etc. Example: the logs are sent to some monitoring tool that
alerts when it sees messages in some pattern and you want a unit test close
to the application.
* To check that logs have an appropriate logging level. Example: oh, this
critical error occurred, so we're going to need something more obvious than a
typical INFO log here.

Logs can be difficult to test. They're often overlooked in tests because
they're a side effect of something else more important: the thing that's worth
logging in the first place. Yet, logs are an essential component of
observability for any modern application. This library aims to make testing
logs easier.

## Features

* In-Memory Capture: Provides a `slog.Handler` that captures `slog.Record`
golang values. This eases testing because there is no need to parse the
formatted data. Instead, work directly with golang data structures.
* Simple: Integrates with the standard library's `*slog.Logger`.
* High-Level Checks: Includes helpers like `HasAttr` to simplify checking
for specific key-value pairs in the captured logs. Use the `InGroup` check to
compose many checks together in the expected shape of your data.
* Concurrency-Safe: Built to ensure that only 1 record is captured at a time in
its entirety.

The said handler adheres to the same tests ([testing/slogtest](https://pkg.go.dev/testing/slogtest))
run against the standard library handlers `slog.TextHandler` and `slog.JSONHandler`.

Also provided are a set of `Check` functions to test attributes. They're higher
order functions: the input specifies something about a target attribute and the
return is a check function to run against a list of attributes. If the check
fails, an error is returned. This functional approach works well when you want
to inspect group attributes.

## The `testing` package

Though this is a testing library, at this time the API doesn't use `testing`.
This is because I couldn't figure out how to write tests that fail
successfully. That is, you can have tests that demonstrate the true negative
case, but AFAIK you can't run them automatically, as actual tests, without the
build failing. Those tests are designed to fail. For this reason, a `Check`
returns an error, which is very easy to test.

It's easy to adapt a `Check` to use `testing`.
```go
package main_test

import (
"log/slog"
"testing"

st "github.com/rafaelespinoza/slogtesting"
)

func TestSimpleExample(t *testing.T) {
attrs := []slog.Attr{slog.String("foo", "bar")}
checks := []st.Check{
st.HasKey("foo"),
st.MissingKey("bar"),
st.HasAttr(slog.String("foo", "bar")),
}
for _, check := range checks {
err := check(attrs)
if err != nil {
t.Error(err)
}
}
}
```

Here's a more complete example using `testing`:
```go
package main_test

import (
"log/slog"
"testing"

st "github.com/rafaelespinoza/slogtesting"
)

func TestCompleteExample(t *testing.T) {
// Configure the handler if needed.
opts := st.AttrHandlerOptions{HandlerOptions: slog.HandlerOptions{Level: slog.LevelInfo}}

// This function is the part of your application to test. It's passed a
// slog.Handler built to capture every processed slog.Record. This example
// accumulates some data and outputs a record at the INFO level.
run := func(h slog.Handler) error {
logger := slog.New(h)

// This output invocation will be recorded b/c the handler's logging
// level will allow calls at the INFO level.
//
// If the handler was a *slog.TextHandler, the output would look similar to:
// time=2006-01-02T15:04:05.012Z level=INFO msg=msg a=b G.c=d G.H.e=f
logger.With("a", "b").WithGroup("G").With("c", "d").WithGroup("H").Info("msg", "e", "f")

// This output won't be recorded b/c the level of the underlying handler
// is above DEBUG.
logger.Debug("You won't see me")

return nil
}
records, err := st.CaptureRecords(&opts, run)
if err != nil {
t.Fatalf("unexpected error while capturing records: %v", err.Error())
}
if len(records) != 1 {
t.Fatalf("wrong number of captured records; got %d, expected %d", len(records), 1)
}

// This is the output data to inspect. Collect the attributes for each
// record that was output by the logger. It will also include the built-in
// attributes: time, level, message.
attrs := st.GetRecordAttrs(records[0])

// Run these tests.
tests := []struct {
name string
check st.Check
}{
{
name: "key " + slog.TimeKey,
check: st.HasKey(slog.TimeKey),
},
{
name: "key " + slog.LevelKey,
check: st.HasKey(slog.LevelKey),
},
{
name: "attribute with key " + slog.MessageKey,
check: st.HasAttr(slog.String(slog.MessageKey, "msg")),
},
{
name: "attribute with key a",
check: st.HasAttr(slog.String("a", "b")),
},
{
name: "group G and attribute with key c",
check: st.InGroup("G", st.HasAttr(slog.String("c", "d"))),
},
{
name: "group G, another group H and attribute with key e",
check: st.InGroup("G", st.InGroup("H", st.HasAttr(slog.String("e", "f")))),
},
{
name: "should not find attribute with key z",
check: st.MissingKey("z"),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.check(attrs)
if err != nil {
t.Errorf("unexpected error %v", err)
}
})
}
}
```