From b4a7f35411fb3d3c1318bdb4435d4f3e9e8a9aaf Mon Sep 17 00:00:00 2001 From: Jonathan Oppenheimer <147infiniti@gmail.com> Date: Wed, 28 Jan 2026 10:45:30 -0500 Subject: [PATCH] test: add log handler for error promotion to `testing.TB` (#259) This adds a test logger for libevm, that treats errors as they _should_ be treated. - Logs at `log.LevelWarn` and above trigger `t.Errorf()`, causing tests to fail - Critical logs fail immediately: Logs at `log.LevelCrit` trigger `t.Fatalf()` - Info/debug logs pass through: Lower severity logs use `t.Logf()` for informational output You can use the logger like this: ```go import ( "github.com/ava-labs/libevm/ethtest" "github.com/ava-labs/libevm/log" ) func TestSomething(t *testing.T) { logger := log.NewLogger(ethtest.NewTBLogHandler(t, log.LevelDebug)) // Or to set globally log.SetDefault(log.NewLogger(ethtest.NewTBLogHandler(t, log.LevelDebug)) } ``` I was thinking about adding tests to show this works, but it seemed silly to test a testing utility. --------- Signed-off-by: Jonathan Oppenheimer <147infiniti@gmail.com> Co-authored-by: Arran Schlosberg <519948+ARR4N@users.noreply.github.com> --- libevm/ethtest/logger.go | 85 +++++++++++++++++++++++++++++++++++ libevm/ethtest/logger_test.go | 65 +++++++++++++++++++++++++++ libevm/hookstest/stub_test.go | 4 +- 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 libevm/ethtest/logger.go create mode 100644 libevm/ethtest/logger_test.go diff --git a/libevm/ethtest/logger.go b/libevm/ethtest/logger.go new file mode 100644 index 0000000000..d78055cf8c --- /dev/null +++ b/libevm/ethtest/logger.go @@ -0,0 +1,85 @@ +// Copyright 2026 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package ethtest + +import ( + "context" + "runtime" + "slices" + "testing" + + "golang.org/x/exp/slog" + + "github.com/ava-labs/libevm/log" +) + +// NewTBLogHandler constructs a [slog.Handler] that propagates logs to [testing.TB]. +// Logs at [log.LevelWarn] or above go to [testing.TB.Errorf], except +// [log.LevelCrit] which goes to [testing.TB.Fatalf]. All other logs go to +// [testing.TB.Logf]. The level parameter controls which logs are enabled. +// +//nolint:thelper // The outputs include the logging site while the TB site is most useful if here +func NewTBLogHandler(tb testing.TB, level slog.Level) slog.Handler { + return &tbHandler{ + tb: tb, + level: level, + } +} + +type tbHandler struct { + tb testing.TB + level slog.Level + attrs []slog.Attr +} + +func (h *tbHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= min(h.level, slog.LevelWarn) +} + +func (h *tbHandler) Handle(_ context.Context, rec slog.Record) error { + to := h.tb.Logf + switch { + case rec.Level >= log.LevelCrit: + to = h.tb.Fatalf + case rec.Level >= log.LevelWarn: + to = h.tb.Errorf + } + + _, file, line, _ := runtime.Caller(3) + + fields := make(map[string]any, len(h.attrs)+rec.NumAttrs()) + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + rec.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + return true + }) + + to("[%s] %s %v - %s:%d", log.LevelAlignedString(rec.Level), rec.Message, fields, file, line) + return nil +} + +func (h *tbHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &tbHandler{ + tb: h.tb, + level: h.level, + attrs: slices.Concat(slices.Clone(h.attrs), attrs), + } +} + +func (h *tbHandler) WithGroup(string) slog.Handler { return h } diff --git a/libevm/ethtest/logger_test.go b/libevm/ethtest/logger_test.go new file mode 100644 index 0000000000..dce83a30ab --- /dev/null +++ b/libevm/ethtest/logger_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package ethtest + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slog" + + "github.com/ava-labs/libevm/log" +) + +type tbRecorder struct { + testing.TB + logged, errored []string +} + +func (r *tbRecorder) Logf(format string, a ...any) { + r.logged = append(r.logged, fmt.Sprintf(format, a...)) +} + +func (r *tbRecorder) Errorf(format string, a ...any) { + r.errored = append(r.errored, fmt.Sprintf(format, a...)) +} + +func TestTBLogHandler(t *testing.T) { + got := &tbRecorder{} + l := log.NewLogger(NewTBLogHandler(got, slog.LevelDebug)) + + l.Debug("Cockroach") // Logf + l.Info("Hello", "who", "world") // Logf + l.Warn("Smoke") // Errorf + l.Error("Fire") // Errorf + // Crit will call os.Exit(1) so we don't test it. + + require.Len(t, got.logged, 2, "Logf() calls") + require.Len(t, got.errored, 2, "Errorf() calls") + + // Check simplest elements without being brittle about exact formatting + // See https://testing.googleblog.com/2015/01/testing-on-toilet-change-detector-tests.html. + assert.Contains(t, got.logged[0], "Cockroach") + assert.Contains(t, got.logged[1], "Hello") + assert.Contains(t, got.logged[1], "who") + assert.Contains(t, got.logged[1], "world") + + assert.Contains(t, got.errored[0], "Smoke") + assert.Contains(t, got.errored[1], "Fire") +} diff --git a/libevm/hookstest/stub_test.go b/libevm/hookstest/stub_test.go index b4d1b6d956..bd6d7bf131 100644 --- a/libevm/hookstest/stub_test.go +++ b/libevm/hookstest/stub_test.go @@ -17,11 +17,9 @@ package hookstest import ( - "os" "testing" "github.com/stretchr/testify/require" - "golang.org/x/exp/slog" "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core" @@ -37,7 +35,7 @@ func TestSetupGenesisBlockWithStub(t *testing.T) { // [log.Crit] being called. l := log.Root() t.Cleanup(func() { log.SetDefault(l) }) - log.SetDefault(log.NewLogger(slog.NewTextHandler(os.Stderr, nil))) + log.SetDefault(log.NewLogger(ethtest.NewTBLogHandler(t, log.LevelDebug))) stub := &Stub{} extras := stub.Register(t)