-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproperty.go
More file actions
311 lines (269 loc) · 9.6 KB
/
property.go
File metadata and controls
311 lines (269 loc) · 9.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
package specta
import (
"context"
"fmt"
"strings"
"time"
"github.com/james-w/specta/conjecture"
)
// T is a testing helper for property-based tests.
// It implements the TestingT interface and can be used with AssertThat and other matchers.
// Unlike testing.T, calling Fatalf does not immediately terminate the test process,
// but instead panics with a sentinel value to stop the current property iteration.
type T struct {
testingT TestingT // Reference to outer testing.T for forwarding logs on failure
failed bool
errors []string
logPassthrough bool // If true, forward Logf calls directly to testingT
Data *conjecture.ConjectureData // ConjectureData for drawing values
Source Source // Deprecated: kept for backward compatibility, will panic if used
// Track generated values for error reporting
generatedValues map[string]string // label -> formatted value
generatedValuesOrder []string // labels in draw order
}
// propertyFailure is a sentinel panic value used to stop property test iterations.
type propertyFailure struct{}
// skipTest is a sentinel panic value used to skip property test iterations.
// This is used by Assume() and by generators when Filter() exhausts retry attempts.
type skipTest struct{}
// Errorf records an error but allows the property test to continue.
// Multiple assertions can be checked before the property fails.
func (t *T) Errorf(format string, args ...any) {
t.errors = append(t.errors, fmt.Sprintf(format, args...))
t.failed = true
}
// Fatalf records an error and immediately stops this property test iteration.
func (t *T) Fatalf(format string, args ...any) {
t.errors = append(t.errors, fmt.Sprintf(format, args...))
t.failed = true
panic(propertyFailure{})
}
// Helper marks the calling function as a test helper.
// This is a no-op for property testing but satisfies the TestingT interface.
func (t *T) Helper() {}
// Logf logs a message.
// During normal test execution, logs are discarded to avoid spam from multiple iterations.
// During the final replay of a failing test, logs are forwarded directly to testing.T.Logf
// with correct line attribution using the Helper() mechanism.
func (t *T) Logf(format string, args ...any) {
if t.logPassthrough && t.testingT != nil {
// Forward directly to underlying testing.T for the final failing iteration
// Call Helper() to mark our function as a helper, so stack traces skip us
// and attribute the log to the user's code
t.testingT.Helper()
t.testingT.Logf(format, args...)
}
// Otherwise, discard logs (don't capture them - we don't need them anymore)
}
// Assume skips the current property test iteration if the condition is false.
// This is useful for filtering generated values that don't meet preconditions.
// Property will track how many tests were skipped and warn if the skip rate is high.
//
// Example:
//
// Property(t, func(t *T) {
// n := Int().Range(1, 100).Draw(t, "n")
// t.Assume(isPrime(n)) // Skip if not prime
// // Test with prime numbers only
// })
func (t *T) Assume(condition bool) {
if !condition {
panic(skipTest{})
}
}
// PropertyOption configures property testing behavior.
type PropertyOption func(*propertyConfig)
type propertyConfig struct {
maxTests int
seed int64
maxShrinks int
}
// MaxTests sets the maximum number of test iterations to run.
// Default is 100.
func MaxTests(n int) PropertyOption {
return func(c *propertyConfig) {
c.maxTests = n
}
}
// Seed sets the random seed for deterministic test generation.
// This is primarily used to reproduce failures.
// Default is based on the current time.
func Seed(seed int64) PropertyOption {
return func(c *propertyConfig) {
c.seed = seed
}
}
// MaxShrinks sets the maximum number of shrinking attempts.
// Default is 1000.
func MaxShrinks(n int) PropertyOption {
return func(c *propertyConfig) {
c.maxShrinks = n
}
}
// Property runs a property-based test.
// It executes the check function multiple times with randomly generated inputs.
// If a failure is detected, it attempts to shrink the input to a minimal failing case.
//
// Example:
//
// Property(t, func(t *specta.T) {
// x := Int().Draw(t, "x")
// y := Int().Draw(t, "y")
// AssertThat(t, x+y, Equal(y+x))
// })
func Property(t TestingT, check func(*T), opts ...PropertyOption) {
t.Helper()
// Apply configuration
cfg := propertyConfig{
maxTests: 100,
seed: time.Now().UnixNano(),
maxShrinks: 1000,
}
for _, opt := range opts {
opt(&cfg)
}
// Run property tests
var skipped int
var tested int
for i := 0; i < cfg.maxTests; i++ {
// Create ConjectureData for this iteration
data := conjecture.NewConjectureData(conjecture.WithSeed(uint64(cfg.seed + int64(i))))
pt := &T{
testingT: t,
Data: data,
Source: nil, // Don't set Source - it's deprecated
generatedValues: make(map[string]string),
}
failed, wasSkipped := runCheck(check, pt)
// Check if test overran (couldn't satisfy constraints)
if data.Status() == conjecture.StatusOverrun {
skipped++
continue
}
if wasSkipped {
skipped++
continue
}
tested++
if failed {
// Property failed - attempt to shrink to minimal failing example
data.Freeze()
failingSeq := data.Sequence()
// Shrink the failing example
shrinkTest := func(d *conjecture.ConjectureData) bool {
testT := &T{
testingT: t,
Data: d,
Source: nil,
generatedValues: make(map[string]string),
}
testFailed, testSkipped := runCheck(check, testT)
// Check the data status - if it overran during replay, this shrink attempt is invalid
if d.Status() == conjecture.StatusOverrun {
return false
}
// Only consider it interesting if it failed (not skipped)
if testFailed && !testSkipped {
d.MarkInteresting("property failed")
return true
}
return false
}
ctx := context.Background()
shrinkResult := conjecture.Shrink(ctx, failingSeq, shrinkTest,
conjecture.WithMaxShrinkCalls(cfg.maxShrinks))
// Report with shrunk example
reportFailure(t, shrinkResult.Sequence, check, i+1, cfg.maxTests, cfg.seed+int64(i), tested, skipped, shrinkResult.Calls)
return
}
}
// All tests passed
// Warn if skip rate is high (>90%) or if no tests ran at all
if tested == 0 {
t.Errorf("All %d property test attempts were skipped - no tests actually ran! Check your generators and constraints.",
skipped)
} else {
skipRate := float64(skipped) / float64(skipped+tested) * 100
if skipRate > 90 {
t.Logf("Warning: %.1f%% of property tests were skipped (%d/%d). Consider narrowing your generator or using Filter().",
skipRate, skipped, skipped+tested)
}
}
}
// runCheck executes the property check function and returns whether it failed or was skipped.
// It recovers from propertyFailure and skipTest panics but re-panics on unexpected panics.
func runCheck(check func(*T), pt *T) (failed bool, skipped bool) {
defer func() {
if r := recover(); r != nil {
// Check if it's our expected skip sentinel
if _, ok := r.(skipTest); ok {
skipped = true
return
}
// Check if it's our expected failure sentinel
if _, ok := r.(propertyFailure); !ok {
// Unexpected panic - re-panic to propagate it
panic(r)
}
}
failed = pt.failed
}()
check(pt)
return
}
// reportFailure creates a detailed error message and fails the test.
func reportFailure(t TestingT, shrunkSeq *conjecture.ChoiceSequence, check func(*T), attempts, maxTests int, seed int64, tested, skipped int, shrinkCalls int) {
t.Helper()
// Replay the shrunk sequence to get the actual failure and capture generated values
// Enable log passthrough so pt.Logf() calls forward directly to t.Logf() with correct line attribution
data := conjecture.ForReplay(shrunkSeq)
finalT := &T{
testingT: t,
logPassthrough: true, // Forward logs directly during final replay
Data: data,
Source: nil,
generatedValues: make(map[string]string),
}
failed, _ := runCheck(check, finalT)
// Sanity check: the replay should have failed
if !failed {
t.Logf("Warning: Property test failed during initial run but passed during replay. This may indicate non-deterministic behavior.")
}
// Build error message
var msg strings.Builder
msg.WriteString("\n=== Property Test Failed ===\n")
msg.WriteString(fmt.Sprintf("Seed: %d\n", seed))
msg.WriteString(fmt.Sprintf("Attempts: %d/%d\n", attempts, maxTests))
if skipped > 0 {
msg.WriteString(fmt.Sprintf("Tested: %d, Skipped: %d (%.1f%% skip rate)\n",
tested, skipped, float64(skipped)/float64(tested+skipped)*100))
}
// Show generated values that were tracked during replay
if len(finalT.generatedValues) > 0 {
msg.WriteString("\nGenerated values:\n")
// Use draw order for intuitive output
for _, label := range finalT.generatedValuesOrder {
msg.WriteString(fmt.Sprintf(" %s = %s\n", label, finalT.generatedValues[label]))
}
}
// Note: Logs from pt.Logf() are forwarded directly to t.Logf() during the final replay
// via the logPassthrough mechanism, so they appear as regular log lines with correct attribution
// Show failure messages
if len(finalT.errors) > 0 {
msg.WriteString("\nFailure:\n")
for _, err := range finalT.errors {
// For multiline errors, indent each line
lines := strings.Split(err, "\n")
for _, line := range lines {
if line != "" {
msg.WriteString(" ")
msg.WriteString(line)
}
msg.WriteString("\n")
}
}
}
// Show reproduction instructions
msg.WriteString(fmt.Sprintf("\nReproduce: Property(t, check, Seed(%d))\n", seed))
t.Errorf("%s", msg.String())
}