Skip to content

Commit 5ae52d8

Browse files
committed
testing/synctest support for Go 1.25+
1 parent 592dce9 commit 5ae52d8

File tree

7 files changed

+224
-1
lines changed

7 files changed

+224
-1
lines changed

engine.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,10 @@ func panicToError(p any, skip int) *testError {
480480
return nil
481481
}
482482

483+
if err, ok := p.(*testError); ok {
484+
return err
485+
}
486+
483487
callers := make([]uintptr, tracebackLen)
484488
callers = callers[:runtime.Callers(skip, callers)]
485489
frames := runtime.CallersFrames(callers)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module pgregory.net/rapid
22

3-
go 1.18
3+
go 1.23

synctest_common.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package rapid
2+
3+
import "testing"
4+
5+
// underlyingTestingT returns the *testing.T associated with tb, if any.
6+
func underlyingTestingT(tbValue TB) (*testing.T, bool) {
7+
if tbValue == nil {
8+
return nil, false
9+
}
10+
return underlyingTestingTPrivate(tb(tbValue))
11+
}
12+
13+
func underlyingTestingTPrivate(tb tb) (*testing.T, bool) {
14+
// Some rapid helpers clone the TB they receive by wrapping it in a new *rapid.T.
15+
// This happens, for example, when Custom generators spin up helper *T instances.
16+
// When SyncTest needs the underlying *testing.T we peel through any number of *rapid.T
17+
// layers until we reach the real testing object.
18+
switch t := any(tb).(type) {
19+
case *testing.T:
20+
return t, true
21+
case *T:
22+
return underlyingTestingTPrivate(t.tb)
23+
case nilTB:
24+
return nil, false
25+
default:
26+
return nil, false
27+
}
28+
}

synctest_disabled.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !go1.25
2+
3+
package rapid
4+
5+
// SyncTest is only available on Go 1.25+.
6+
func SyncTest(t *T, _ func(*T)) {
7+
if t == nil {
8+
panic("rapid.SyncTest requires *rapid.T")
9+
}
10+
t.Helper()
11+
t.Fatalf("[rapid] SyncTest requires Go 1.25 or newer")
12+
}

synctest_enabled.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//go:build go1.25
2+
3+
package rapid
4+
5+
import (
6+
"testing"
7+
"testing/synctest"
8+
)
9+
10+
// SyncTest runs prop within a testing/synctest bubble.
11+
// Callers must already be executing inside a rapid.Check-style helper;
12+
// SyncTest forwards failures to the parent *rapid.T and restores its state afterwards.
13+
func SyncTest(t *T, prop func(*T)) {
14+
if t == nil {
15+
panic("rapid.SyncTest requires *rapid.T")
16+
}
17+
18+
t.Helper()
19+
20+
testT, ok := underlyingTestingT(t.tb)
21+
if !ok {
22+
t.Fatalf("[rapid] SyncTest requires a *testing.T backing the current rapid test")
23+
return
24+
}
25+
26+
syncTestWithinRapid(t, testT, prop)
27+
}
28+
29+
func syncTestWithinRapid(t *T, parent *testing.T, prop func(*T)) {
30+
// synctest.Test converts failures inside the bubble into parent.FailNow (runtime.Goexit),
31+
// which would bypass rapid's panic-based failure capture/shrinking. Run the bubble in a
32+
// separate goroutine, swallow failures inside the bubble, and re-panic outside as a
33+
// *testError so checkOnce can shrink and generate failfiles as usual.
34+
resultCh := make(chan *testError, 1)
35+
36+
go func() {
37+
var captured *testError
38+
returned := false
39+
defer func() {
40+
if r := recover(); r != nil {
41+
captured = panicToError(r, 3)
42+
} else if !returned && captured == nil {
43+
captured = panicToError(stopTest("[rapid] SyncTest aborted via testing.FailNow"), 3)
44+
}
45+
resultCh <- captured
46+
}()
47+
48+
synctest.Test(parent, func(st *testing.T) {
49+
st.Helper()
50+
51+
prevTB := t.tb
52+
prevTBLog := t.tbLog // preserved so we keep the original logging behaviour
53+
prevCtx := t.ctx
54+
prevCancel := t.cancelCtx
55+
prevCleanups := t.cleanups
56+
prevCleaning := t.cleaning.Load()
57+
58+
t.tb = st
59+
// Reset per-run state before the property runs in the bubble.
60+
// No lock is needed because no other goroutine touches t before we hand control to prop.
61+
t.ctx = nil
62+
t.cancelCtx = nil
63+
t.cleanups = nil
64+
t.cleaning.Store(false)
65+
66+
var panicValue any
67+
defer func() {
68+
if r := recover(); r != nil {
69+
panicValue = r
70+
}
71+
72+
func() {
73+
// Always run rapid cleanups, even if the property panicked.
74+
defer func() {
75+
if r := recover(); r != nil {
76+
panicValue = r
77+
}
78+
}()
79+
t.cleanup()
80+
}()
81+
82+
t.tb = prevTB
83+
t.tbLog = prevTBLog
84+
t.ctx = prevCtx
85+
t.cancelCtx = prevCancel
86+
t.cleanups = prevCleanups
87+
t.cleaning.Store(prevCleaning)
88+
89+
if panicValue != nil {
90+
captured = panicToError(panicValue, 3)
91+
}
92+
}()
93+
94+
prop(t)
95+
t.failOnError()
96+
})
97+
98+
returned = true
99+
}()
100+
101+
if err := <-resultCh; err != nil {
102+
panic(err)
103+
}
104+
}

synctest_enabled_internal_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//go:build go1.25
2+
3+
package rapid
4+
5+
import (
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestSyncTest_FailureIsCapturedForShrinking(t *testing.T) {
11+
rt := newT(tb(t), newRandomBitStream(1, true), false, nil)
12+
13+
err := checkOnce(rt, func(t *T) {
14+
SyncTest(t, func(inner *T) {
15+
inner.Fatalf("boom")
16+
})
17+
})
18+
19+
if err == nil {
20+
t.Fatalf("checkOnce did not report failure from SyncTest")
21+
}
22+
if !err.isStopTest() {
23+
t.Fatalf("expected stopTest failure, got %T (%v)", err.data, err)
24+
}
25+
if !strings.Contains(errorString(err), "boom") {
26+
t.Fatalf("missing failure message: %q", errorString(err))
27+
}
28+
if !strings.Contains(traceback(err), "synctest_enabled_internal_test.go") {
29+
t.Fatalf("traceback does not include property call site:\n%v", traceback(err))
30+
}
31+
}

synctest_enabled_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build go1.25
2+
3+
package rapid_test
4+
5+
import (
6+
"sync/atomic"
7+
"testing"
8+
"testing/synctest"
9+
"time"
10+
11+
"pgregory.net/rapid"
12+
)
13+
14+
func TestSyncTest(t *testing.T) {
15+
rapid.Check(t, func(rt *rapid.T) {
16+
var cleaned atomic.Bool
17+
18+
rapid.SyncTest(rt, func(inner *rapid.T) {
19+
inner.Cleanup(func() {
20+
cleaned.Store(true)
21+
})
22+
23+
const sleep = 2 * time.Second
24+
start := time.Now()
25+
time.Sleep(sleep)
26+
if got := time.Since(start); got != sleep {
27+
inner.Fatalf("virtual time advanced by %v, want %v", got, sleep)
28+
}
29+
30+
done := make(chan struct{})
31+
go func() { close(done) }()
32+
synctest.Wait()
33+
select {
34+
case <-done:
35+
default:
36+
inner.Fatalf("goroutine did not finish inside synctest bubble")
37+
}
38+
})
39+
40+
if !cleaned.Load() {
41+
rt.Fatalf("cleanup registered inside SyncTest did not run")
42+
}
43+
})
44+
}

0 commit comments

Comments
 (0)