Skip to content

Commit 18dce5c

Browse files
authored
fix: synchronize test logs to prevent t.Log race condition (#146)
If `(*testing.T).Log` is called concurrently of a test exiting, it's picked up by the race detector. This adds logic to allow slogtest to detect when a test has exited, and ensure it doesn't call `(*testing.T).Log` after that happens.
1 parent 047b2a2 commit 18dce5c

File tree

2 files changed

+70
-11
lines changed

2 files changed

+70
-11
lines changed

sloggers/slogtest/t.go

+33-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"log"
1111
"os"
12+
"sync"
1213
"testing"
1314

1415
"cdr.dev/slog"
@@ -28,26 +29,50 @@ type Options struct {
2829
// IgnoreErrors causes the test logger to not fatal the test
2930
// on Fatal and not error the test on Error or Critical.
3031
IgnoreErrors bool
32+
// SkipCleanup skips adding a t.Cleanup call that prevents the logger from
33+
// logging after a test has exited. This is necessary because race
34+
// conditions exist when t.Log is called concurrently of a test exiting. Set
35+
// to true if you don't need this behavior.
36+
SkipCleanup bool
3137
}
3238

3339
// Make creates a Logger that writes logs to tb in a human readable format.
3440
func Make(tb testing.TB, opts *Options) slog.Logger {
3541
if opts == nil {
3642
opts = &Options{}
3743
}
38-
return slog.Make(testSink{
44+
45+
sink := &testSink{
3946
tb: tb,
4047
opts: opts,
41-
})
48+
}
49+
if !opts.SkipCleanup {
50+
tb.Cleanup(func() {
51+
sink.mu.Lock()
52+
defer sink.mu.Unlock()
53+
sink.testDone = true
54+
})
55+
}
56+
57+
return slog.Make(sink)
4258
}
4359

4460
type testSink struct {
45-
tb testing.TB
46-
opts *Options
47-
stdlib bool
61+
tb testing.TB
62+
opts *Options
63+
mu sync.RWMutex
64+
testDone bool
4865
}
4966

50-
func (ts testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
67+
func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
68+
ts.mu.RLock()
69+
defer ts.mu.RUnlock()
70+
71+
// Don't log after the test this sink was created in has finished.
72+
if ts.testDone {
73+
return
74+
}
75+
5176
// The testing package logs to stdout and not stderr.
5277
s := entryhuman.Fmt(os.Stdout, ent)
5378

@@ -65,12 +90,12 @@ func (ts testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
6590
}
6691
}
6792

68-
func (ts testSink) Sync() {}
93+
func (ts *testSink) Sync() {}
6994

7095
var ctx = context.Background()
7196

7297
func l(t testing.TB) slog.Logger {
73-
return Make(t, nil)
98+
return Make(t, &Options{SkipCleanup: true})
7499
}
75100

76101
// Debug logs the given msg and fields to t via t.Log at the debug level.

sloggers/slogtest/t_test.go

+37-3
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,48 @@ func TestIgnoreErrors(t *testing.T) {
4545
l.Fatal(bg, "hello")
4646
}
4747

48+
func TestCleanup(t *testing.T) {
49+
t.Parallel()
50+
51+
tb := &fakeTB{}
52+
l := slogtest.Make(tb, &slogtest.Options{})
53+
54+
for _, fn := range tb.cleanups {
55+
fn()
56+
}
57+
58+
// This shoud not log since the logger was cleaned up.
59+
l.Info(bg, "hello")
60+
assert.Equal(t, "no logs", 0, tb.logs)
61+
}
62+
63+
func TestSkipCleanup(t *testing.T) {
64+
t.Parallel()
65+
66+
tb := &fakeTB{}
67+
slogtest.Make(tb, &slogtest.Options{
68+
SkipCleanup: true,
69+
})
70+
71+
assert.Len(t, "no cleanups", 0, tb.cleanups)
72+
}
73+
4874
var bg = context.Background()
4975

5076
type fakeTB struct {
5177
testing.TB
5278

53-
errors int
54-
fatals int
79+
logs int
80+
errors int
81+
fatals int
82+
cleanups []func()
5583
}
5684

5785
func (tb *fakeTB) Helper() {}
5886

59-
func (tb *fakeTB) Log(v ...interface{}) {}
87+
func (tb *fakeTB) Log(v ...interface{}) {
88+
tb.logs++
89+
}
6090

6191
func (tb *fakeTB) Error(v ...interface{}) {
6292
tb.errors++
@@ -66,3 +96,7 @@ func (tb *fakeTB) Fatal(v ...interface{}) {
6696
tb.fatals++
6797
panic("")
6898
}
99+
100+
func (tb *fakeTB) Cleanup(fn func()) {
101+
tb.cleanups = append(tb.cleanups, fn)
102+
}

0 commit comments

Comments
 (0)