Skip to content

Commit 815f4fa

Browse files
committed
test: add fuzz and stress tests for concurrent safety
- Add stress_test.go with 17 tests: edge cases, concurrent stress tests for all handlers (Fanout, Failover, Pool, Router, FirstMatch, Pipe, Recovery, InlineHandler), and composed handler tests - Add fuzz_test.go with 5 fuzz targets: FanoutHandle, FailoverHandle, PoolHandle, FirstMatchHandle, RouterPredicates - Expose known race condition in PoolHandler (rand.Source not thread-safe) - All tests pass with -race (Pool test skipped due to known bug)
1 parent 047aba8 commit 815f4fa

3 files changed

Lines changed: 727 additions & 0 deletions

File tree

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ bench:
1212
watch-bench:
1313
reflex -t 50ms -s -- sh -c 'go test -benchmem -count 3 -bench ./...'
1414

15+
fuzz:
16+
go test -fuzz -fuzztime=10s ./...
17+
watch-fuzz:
18+
reflex -t 50ms -s -- sh -c 'go test -fuzz -fuzztime=10s ./...'
19+
1520
coverage:
1621
go test -v -coverprofile=cover.out -covermode=atomic ./...
1722
go tool cover -html=cover.out -o cover.html

fuzz_test.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package slogmulti
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func buildFuzzRecord(levelInt int, msg string, attrCount int) slog.Record {
16+
level := slog.Level(levelInt)
17+
r := slog.NewRecord(time.Now(), level, msg, 0)
18+
for i := 0; i < attrCount; i++ {
19+
r.AddAttrs(slog.Int(fmt.Sprintf("k%d", i), i))
20+
}
21+
return r
22+
}
23+
24+
func FuzzFanoutHandle(f *testing.F) {
25+
f.Add(0, "hello", 3)
26+
f.Add(-4, "", 0)
27+
f.Add(4, "warn msg", 1)
28+
f.Add(8, strings.Repeat("x", 1000), 50)
29+
30+
f.Fuzz(func(t *testing.T, levelInt int, msg string, attrCount int) {
31+
if attrCount < 0 {
32+
attrCount = 0
33+
}
34+
if attrCount > 50 {
35+
attrCount = 50
36+
}
37+
38+
level := slog.Level(levelInt)
39+
h1 := newCountingHandler(slog.LevelDebug)
40+
h2 := newCountingHandler(slog.LevelWarn)
41+
h3 := newCountingHandler(slog.LevelDebug)
42+
43+
fanout := Fanout(h1, h2, h3)
44+
r := buildFuzzRecord(levelInt, msg, attrCount)
45+
err := fanout.Handle(context.Background(), r)
46+
assert.NoError(t, err)
47+
48+
// Fanout only calls Handle on enabled handlers
49+
var expected1, expected2, expected3 int64
50+
if level >= slog.LevelDebug {
51+
expected1 = 1
52+
expected3 = 1
53+
}
54+
if level >= slog.LevelWarn {
55+
expected2 = 1
56+
}
57+
58+
assert.Equal(t, expected1, h1.handleCount.Load(), "h1")
59+
assert.Equal(t, expected2, h2.handleCount.Load(), "h2")
60+
assert.Equal(t, expected3, h3.handleCount.Load(), "h3")
61+
})
62+
}
63+
64+
func FuzzFailoverHandle(f *testing.F) {
65+
f.Add(0, "test", true, false)
66+
f.Add(0, "test", false, false)
67+
f.Add(0, "test", true, true)
68+
f.Add(4, "", false, true)
69+
70+
f.Fuzz(func(t *testing.T, levelInt int, msg string, firstFails bool, secondFails bool) {
71+
// errorHandler.Enabled always returns true, countingHandler checks minLevel.
72+
// Use errorHandler for both fail and success paths so Enabled is always true,
73+
// avoiding level-based skipping that complicates assertions.
74+
h1err := &errorHandler{err: errors.New("h1 fail")}
75+
h1ok := &errorHandler{err: nil}
76+
h2err := &errorHandler{err: errors.New("h2 fail")}
77+
h2ok := &errorHandler{err: nil}
78+
79+
var h1, h2 *errorHandler
80+
if firstFails {
81+
h1 = h1err
82+
} else {
83+
h1 = h1ok
84+
}
85+
if secondFails {
86+
h2 = h2err
87+
} else {
88+
h2 = h2ok
89+
}
90+
91+
handler := Failover()(h1, h2)
92+
r := buildFuzzRecord(levelInt, msg, 0)
93+
err := handler.Handle(context.Background(), r)
94+
95+
if !firstFails {
96+
assert.NoError(t, err)
97+
assert.Equal(t, int64(1), h1.handleCount.Load())
98+
assert.Equal(t, int64(0), h2.handleCount.Load())
99+
} else if !secondFails {
100+
assert.NoError(t, err)
101+
assert.Equal(t, int64(1), h2.handleCount.Load())
102+
} else {
103+
assert.Error(t, err)
104+
}
105+
})
106+
}
107+
108+
func FuzzPoolHandle(f *testing.F) {
109+
f.Add(0, "hello")
110+
f.Add(-4, "")
111+
f.Add(8, strings.Repeat("a", 500))
112+
113+
f.Fuzz(func(t *testing.T, levelInt int, msg string) {
114+
// Use errorHandler (always enabled) to avoid level-based skipping
115+
handlers := make([]*errorHandler, 3)
116+
slogHandlers := make([]slog.Handler, 3)
117+
for i := range handlers {
118+
handlers[i] = &errorHandler{err: nil}
119+
slogHandlers[i] = handlers[i]
120+
}
121+
122+
pool := Pool()(slogHandlers...)
123+
124+
const iterations = 100
125+
for i := 0; i < iterations; i++ {
126+
r := buildFuzzRecord(levelInt, msg, 0)
127+
err := pool.Handle(context.Background(), r)
128+
assert.NoError(t, err)
129+
}
130+
131+
var total int64
132+
for _, h := range handlers {
133+
total += h.handleCount.Load()
134+
}
135+
assert.Equal(t, int64(iterations), total)
136+
})
137+
}
138+
139+
func FuzzFirstMatchHandle(f *testing.F) {
140+
f.Add(0, "hello world")
141+
f.Add(4, "error occurred")
142+
f.Add(-4, "debug")
143+
f.Add(8, "")
144+
145+
f.Fuzz(func(t *testing.T, levelInt int, msg string) {
146+
// Use errorHandler (always enabled) as sinks to avoid level gating
147+
errorSink := &errorHandler{err: nil}
148+
infoSink := &errorHandler{err: nil}
149+
catchAll := &errorHandler{err: nil}
150+
151+
handler := Router().
152+
Add(errorSink, LevelIs(slog.LevelError)).
153+
Add(infoSink, LevelIs(slog.LevelInfo)).
154+
Add(catchAll).
155+
FirstMatch().
156+
Handler()
157+
158+
r := buildFuzzRecord(levelInt, msg, 0)
159+
err := handler.Handle(context.Background(), r)
160+
assert.NoError(t, err)
161+
162+
level := slog.Level(levelInt)
163+
total := errorSink.handleCount.Load() + infoSink.handleCount.Load() + catchAll.handleCount.Load()
164+
165+
if level == slog.LevelError {
166+
assert.Equal(t, int64(1), errorSink.handleCount.Load())
167+
assert.Equal(t, int64(1), total)
168+
} else if level == slog.LevelInfo {
169+
assert.Equal(t, int64(1), infoSink.handleCount.Load())
170+
assert.Equal(t, int64(1), total)
171+
} else {
172+
// Catch-all gets it (no predicate, always matches)
173+
assert.Equal(t, int64(1), catchAll.handleCount.Load())
174+
assert.Equal(t, int64(1), total)
175+
}
176+
})
177+
}
178+
179+
func FuzzRouterPredicates(f *testing.F) {
180+
f.Add("hello world", 0)
181+
f.Add("", -4)
182+
f.Add("error in database", 8)
183+
f.Add(strings.Repeat("x", 1000), 4)
184+
185+
f.Fuzz(func(t *testing.T, msg string, levelInt int) {
186+
level := slog.Level(levelInt)
187+
r := slog.NewRecord(time.Now(), level, msg, 0)
188+
r.AddAttrs(slog.String("key", "value"), slog.Int("num", 42))
189+
ctx := context.Background()
190+
191+
// All of these must not panic regardless of input
192+
LevelIs(slog.LevelInfo, slog.LevelError)(ctx, r)
193+
LevelIsNot(slog.LevelInfo)(ctx, r)
194+
MessageIs(msg)(ctx, r)
195+
MessageIsNot(msg)(ctx, r)
196+
MessageContains("error")(ctx, r)
197+
MessageNotContains("error")(ctx, r)
198+
AttrValueIs("key", "value")(ctx, r)
199+
AttrKindIs("key", slog.KindString)(ctx, r)
200+
})
201+
}

0 commit comments

Comments
 (0)