Golang How to Test Slog.TextHandler
I explain the easiest way to test slog Handlers in Google's Go (Golang)
Explaining the issue with testing slog.TextHandler
I came across a case where I needed to test a slog.TextHandler using Google Go’s testing package.
My initial thought was to use simple os.Stdout redirection:
old := os.Stdout
r,w,err := os.Pipe()
if err != nil {
t.Error("could not create pipes")
}
os.Stdout = w
logger.Info("message","key","value")
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
if !strings.Contains(string(out), "message") {
t.Errorf("expected %s but got %s\n","message",string(out))
}
This pattern is pretty typical when you want to redirect os.Stdout. We are capturing the “old”
os.Stdout for later so we can reset it back to what it was. We create a couple of pipes, set
os.Stdout to the writer, perform some action, then close the writer. Once the writer is closed,
we can restore our original os.Stdout that we saved in “old”. Then we get the captured bytes
from out via io.ReadAll(r). If this was fmt.Println("hello, world!") then we would be done.
I assumed I’d be able to capture the output from Stdout but I was wrong. When I
used t.Log(string(out)) I realized it was not capturing any output. Instead of figuring out a
solution, I decided to adjust my code.
Note: After some review and reading, I realized that in my TestMain function I had set the slog.TextHandler had a previous os.Stdout it wasn’t redirecting correctly but, instead, was redirecting to where it was assigned initially.
- Create a reusable factory function
- Pass in the writer and level parameter
- In my unit test, instead of passing
os.Stdout, I can pass in abytes.Buffer
Create A Reusable slog.Handler Factory Function
Instead of fighting with os.Stdout, I decided to just upgrade my code to be more testable and
maintanable. We can create a reusable factory function to return a logger at will. All the caller
needs to do is pass in the writer and the level and we are set to go.
func NewLogger(w io.Writer,levl slog.Level) *slog.Logger {
return slog.New(slog.NewTextHandler(w,&slog.HandlerOptions{
Level: levl,
}))
}
Now we can use this in our production code to setup a new logger:
logger := NewLogger(os.Stdout,slog.LevelInfo)
This is great for production because it is flexible and maintainable. For testing, it works well,
too, because we can now pass in a bytes.Buffer as the writer and easily access it’s content.
For testing, we can write the following unit test:
func Test_NewLogger(t *testing.T) {
var buf bytes.Buffer
msg := "hello"
key := "world"
val := "!"
logger := NewLogger(&buf, slog.LevelInfo)
logger.Info(msg,key,val)
if !strings.Contains(buf.String(), msg) {
t.Errorf("Expected output to contain %s but got: %s\n",msg,buf.String())
}
}
Conclusion
Sometimes the best solution is to write more testable code. I am sure someone smarter than me could get it to work using os.Stdout, but for me it was just easier to create a more flexable structure.