From d5c9fe1b0e356e42ec572a722d6389c456d3c5fa Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 17 Apr 2026 14:39:37 +0200 Subject: [PATCH] feat(condition): added opt-in support for synctest in async assertions doc: updated docs for synctest support, with additional ad'hoc examples for synctest doc: fixed rendering of testable examples Signed-off-by: Frederic BIDON --- assert/assert_adhoc_example_9_test.go | 116 +++ assert/assert_assertions.go | 37 +- assert/assert_format.go | 4 +- assert/assert_forward.go | 20 - assert/assert_forward_test.go | 58 -- assert/assert_types.go | 57 ++ docs/doc-site/api/condition.md | 445 ++++++++- docs/doc-site/api/metrics.md | 4 +- docs/doc-site/api/safety.md | 4 +- docs/doc-site/project/maintainers/ROADMAP.md | 2 +- docs/doc-site/usage/CHANGES.md | 15 + docs/doc-site/usage/EXAMPLES.md | 77 ++ .../hugo/layouts/partials/custom-header.html | 12 + hack/doc-site/hugo/metrics.yaml | 4 +- internal/assertions/condition.go | 137 ++- .../assertions/condition_synctest_test.go | 910 ++++++++++++++++++ internal/assertions/condition_test.go | 618 +----------- internal/assertions/generics.go | 64 +- require/require_adhoc_example_9_test.go | 116 +++ require/require_assertions.go | 37 +- require/require_format.go | 4 +- require/require_forward.go | 28 - require/require_forward_test.go | 50 - require/require_types.go | 57 ++ 24 files changed, 2067 insertions(+), 809 deletions(-) create mode 100644 assert/assert_adhoc_example_9_test.go create mode 100644 internal/assertions/condition_synctest_test.go create mode 100644 require/require_adhoc_example_9_test.go diff --git a/assert/assert_adhoc_example_9_test.go b/assert/assert_adhoc_example_9_test.go new file mode 100644 index 000000000..13727ca6b --- /dev/null +++ b/assert/assert_adhoc_example_9_test.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package assert_test + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +// ExampleWithSynctest_asyncReady demonstrates opting into [testing/synctest] +// bubble polling via [assert.WithSynctest]. Time operations inside the bubble +// use a fake clock — a 1-hour timeout with a 1-minute tick completes in +// microseconds of real wall-clock time while remaining deterministic. +// +// Prefer this wrapper when the condition is pure compute or uses [time.Sleep] +// internally. See [assert.WithSynctest] for the constraints (no real I/O, no +// external goroutines driving state change). +func ExampleEventually_withSyncTest() { + t := new(testing.T) // normally provided by test + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + result := assert.Eventually(t, assert.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", result, attempts.Load()) + + // Output: ready: true, attempts: 5 +} + +// ExampleWithSynctestContext_healthCheck demonstrates the context/error +// variant of the synctest opt-in. [assert.WithSynctestContext] wraps a +// [func(context.Context) error] condition for fake-time polling. +func ExampleEventually_withContext() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + healthCheck := func(_ context.Context) error { + if attempts.Add(1) < 3 { + return errors.New("service not ready") + } + + return nil + } + + result := assert.Eventually(t, assert.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", result, attempts.Load()) + + // Output: healthy: true, attempts: 3 +} + +// ExampleWithSynctest_never demonstrates [assert.Never] with the synctest +// opt-in. The condition is polled over the fake-time window without costing +// real wall-clock time. +func ExampleNever_withSyncTest() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + result := assert.Never(t, assert.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %t", result) + + // Output: never flipped: true +} + +// ExampleWithSynctest_consistently demonstrates [assert.Consistently] with +// the synctest opt-in — asserting an invariant holds across the entire +// observation window under deterministic fake time. +func ExampleConsistently_withSynctest() { + t := new(testing.T) // normally provided by test + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + result := assert.Consistently(t, assert.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %t", result) + + // Output: invariant held: true +} + +// ExampleWithSynctestCollect_convergence demonstrates [assert.EventuallyWith] +// with [assert.WithSynctestCollect] — a [CollectT]-based condition polled +// inside a synctest bubble. Useful when the condition uses several require / +// assert calls and you want deterministic retry behavior. +func ExampleEventuallyWith_withSynctest() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + cond := func(c *assert.CollectT) { + n := attempts.Add(1) + assert.Equal(c, int32(3), n, "not yet converged") + } + + result := assert.EventuallyWith(t, assert.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", result, attempts.Load()) + + // Output: converged: true, attempts: 3 +} diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index ea9b3c6f6..7d78b3485 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -79,6 +79,14 @@ func Condition(t T, comp func() bool, msgAndArgs ...any) bool { // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires [*testing.T]). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -507,6 +515,14 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) bool { // To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << // timeout). // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires `*testing.T`). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -575,6 +591,14 @@ func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim // // See [Eventually] for the general panic recovery semantics. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctestCollect] (or [WithSynctestCollectContext]) +// to run the polling loop inside a [testing/synctest] bubble, which uses +// a fake clock. This eliminates timing-induced flakiness and makes the +// tick count deterministic. See [WithSynctest] for the constraints (no +// real I/O in the condition, requires [*testing.T]). +// // # Examples // // success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond @@ -1940,17 +1964,26 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] to run the polling loop inside a +// [testing/synctest] bubble, which uses a fake clock. This eliminates +// timing-induced flakiness and makes the tick count deterministic. See +// [WithSynctest] for the constraints (no real I/O in the condition, +// requires [*testing.T]). Note: [Never] does not accept the context/error +// form of condition, so [WithSynctestContext] does not apply here. +// // # Examples // // success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and continues execution. -func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.Never(t, condition, timeout, tick, msgAndArgs...) + return assertions.Never[C](t, condition, timeout, tick, msgAndArgs...) } // Nil asserts that the specified object is nil. diff --git a/assert/assert_format.go b/assert/assert_format.go index ada936e51..bd5b00453 100644 --- a/assert/assert_format.go +++ b/assert/assert_format.go @@ -768,11 +768,11 @@ func NegativeTf[SignedNumber SignedNumeric](t T, e SignedNumber, msg string, arg // Neverf is the same as [Never], but it accepts a format string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. -func Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) bool { +func Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.Never(t, condition, timeout, tick, forwardArgs(msg, args)) + return assertions.Never[C](t, condition, timeout, tick, forwardArgs(msg, args)) } // Nilf is the same as [Nil], but it accepts a format string to format arguments like [fmt.Printf]. diff --git a/assert/assert_forward.go b/assert/assert_forward.go index e97d2c727..f1f7c8b24 100644 --- a/assert/assert_forward.go +++ b/assert/assert_forward.go @@ -1010,26 +1010,6 @@ func (a *Assertions) Negativef(e any, msg string, args ...any) bool { return assertions.Negative(a.T, e, forwardArgs(msg, args)) } -// Never is the same as [Never], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Never(condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.Never(a.T, condition, timeout, tick, msgAndArgs...) -} - -// Neverf is the same as [Assertions.Never], but it accepts a format string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Neverf(condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.Never(a.T, condition, timeout, tick, forwardArgs(msg, args)) -} - // Nil is the same as [Nil], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and continues execution. diff --git a/assert/assert_forward_test.go b/assert/assert_forward_test.go index 14d14b900..bf6846731 100644 --- a/assert/assert_forward_test.go +++ b/assert/assert_forward_test.go @@ -1415,35 +1415,6 @@ func TestAssertionsNegative(t *testing.T) { }) } -func TestAssertionsNever(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Never(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) - if !result { - t.Error("Assertions.Never should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Never(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond) - if result { - t.Error("Assertions.Never should return false on failure") - } - if !mock.failed { - t.Error("Assertions.Never should mark test as failed") - } - }) -} - func TestAssertionsNil(t *testing.T) { t.Parallel() @@ -3725,35 +3696,6 @@ func TestAssertionsNegativef(t *testing.T) { }) } -func TestAssertionsNeverf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Neverf(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if !result { - t.Error("Assertions.Neverf should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Neverf(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if result { - t.Error("Assertions.Neverf should return false on failure") - } - if !mock.failed { - t.Error("Assertions.Neverf should mark test as failed") - } - }) -} - func TestAssertionsNilf(t *testing.T) { t.Parallel() diff --git a/assert/assert_types.go b/assert/assert_types.go index 07c9c76be..acf7e0509 100644 --- a/assert/assert_types.go +++ b/assert/assert_types.go @@ -35,6 +35,9 @@ type ( // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. // // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + // + // The [WithSynctestCollect] and [WithSynctestCollectContext] wrappers opt a + // call into fake-time polling. See [WithSynctest] for details. CollectibleConditioner = assertions.CollectibleConditioner // ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful @@ -44,6 +47,9 @@ type ( // Conditioner is a function used in asynchronous condition assertions. // // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + // + // The [WithSynctest] and [WithSynctestContext] wrappers opt a call into + // fake-time polling via [testing/synctest]. See [WithSynctest] for details. Conditioner = assertions.Conditioner // ErrorAssertionFunc is a common function prototype when validating an error value. Can be useful @@ -61,6 +67,14 @@ type ( // NOTE: unfortunately complex64 and complex128 are not supported. Measurable = assertions.Measurable + // NeverConditioner is a function used by [Never]. + // + // Unlike [Conditioner], [Never] does not accept the context-returning-error + // form to avoid the double-negation confusion ("never returns no error"). + // + // The [WithSynctest] wrapper opts a call into fake-time polling. + NeverConditioner = assertions.NeverConditioner + // Ordered is a standard ordered type (i.e. types that support "<": [cmp.Ordered]) plus []byte and [time.Time]. // // This is used by [GreaterT], [GreaterOrEqualT], [LessT], [LessOrEqualT], [IsIncreasingT], [IsDecreasingT]. @@ -101,6 +115,49 @@ type ( // ValueAssertionFunc is a common function prototype when validating a single value. Can be useful // for table driven tests. ValueAssertionFunc = assertions.ValueAssertionFunc + + // WithSynctest wraps a [func() bool] condition to run [Eventually] / + // [Never] / [Consistently] polling inside a [testing/synctest] bubble, + // so `time.Ticker`, `time.After`, and `context.WithTimeout` use a fake + // clock. Activation requires the caller to pass a real `*testing.T`; + // with mocks or other [T] implementations, the wrapper falls back to + // real-time polling. + // + // # When to use + // + // Use when the condition is pure compute, relies on `time.Sleep`, or + // coordinates via channels created inside the condition. Fake time + // eliminates timing-induced flakiness and enables deterministic tick + // counts. + // + // # When not to use + // + // Do NOT use when the condition performs real I/O (network, filesystem, + // syscalls): those block goroutines non-durably, so the fake clock + // stalls and the timeout may not fire. Also do NOT use inside a test + // that is already running in a [synctest.Test] bubble — nested bubbles + // are forbidden and will panic. + // + // # Shared state + // + // The condition may read and write variables captured from the enclosing + // scope; condition execution is serialized by design (see [Eventually]'s + // Concurrency section). Avoid sharing channels or mutexes with goroutines + // outside the bubble, as this will stall the fake clock. + WithSynctest = assertions.WithSynctest + + // WithSynctestCollect is the [func(*CollectT)] counterpart of + // [WithSynctest] for use with [EventuallyWith]. See [WithSynctest] for details. + WithSynctestCollect = assertions.WithSynctestCollect + + // WithSynctestCollectContext is the [func(context.Context, *CollectT)] + // counterpart of [WithSynctest] for use with [EventuallyWith]. See + // [WithSynctest] for details. + WithSynctestCollectContext = assertions.WithSynctestCollectContext + + // WithSynctestContext is the [func(context.Context) error] counterpart + // of [WithSynctest]. See [WithSynctest] for details. + WithSynctestContext = assertions.WithSynctestContext ) // Type declarations for backward compatibility. diff --git a/docs/doc-site/api/condition.md b/docs/doc-site/api/condition.md index 2b164c6f3..8ff6b8484 100644 --- a/docs/doc-site/api/condition.md +++ b/docs/doc-site/api/condition.md @@ -34,7 +34,7 @@ Generic assertions are marked with a {{% icon icon="star" color=orange %}}. - [Consistently[C Conditioner]](#consistentlyc-conditioner) | star | orange - [Eventually[C Conditioner]](#eventuallyc-conditioner) | star | orange - [EventuallyWith[C CollectibleConditioner]](#eventuallywithc-collectibleconditioner) | star | orange -- [Never](#never) | angles-right +- [Never[C NeverConditioner]](#neverc-neverconditioner) | star | orange ``` ### Condition{#condition} @@ -148,7 +148,7 @@ func main() { |--|--| | [`assertions.Condition(t T, comp func() bool, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Condition) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Condition](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L26) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Condition](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L28) {{% /tab %}} {{< /tabs >}} @@ -192,6 +192,14 @@ See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Even See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run +the polling loop inside a [testing/synctest] bubble, which uses a fake +clock. This eliminates timing-induced flakiness and makes the tick count +deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in +the condition, requires [*testing.T]). + {{% expand title="Examples" %}} {{< tabs >}} {{% tab title="Usage" %}} @@ -310,6 +318,43 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestConsistently(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + result := assert.Consistently(t, assert.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %t", result) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -422,6 +467,43 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestConsistently(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + require.Consistently(t, require.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -449,7 +531,7 @@ func main() { |--|--| | [`assertions.Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Consistently) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Consistently](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L226) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Consistently](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L253) {{% /tab %}} {{< /tabs >}} @@ -525,6 +607,14 @@ counter-intuitive results, such as ticks or timeouts not firing in time as expec To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << timeout). +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run +the polling loop inside a [testing/synctest] bubble, which uses a fake +clock. This eliminates timing-induced flakiness and makes the tick count +deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in +the condition, requires `*testing.T`). + {{% expand title="Examples" %}} {{< tabs >}} {{% tab title="Usage" %}} @@ -650,6 +740,89 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventually(t *testing.T) +package main + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + healthCheck := func(_ context.Context) error { + if attempts.Add(1) < 3 { + return errors.New("service not ready") + } + + return nil + } + + result := assert.Eventually(t, assert.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /card %}} + + +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventually(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + result := assert.Eventually(t, assert.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -770,6 +943,89 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventually(t *testing.T) +package main + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + healthCheck := func(_ context.Context) error { + if attempts.Add(1) < 3 { + return errors.New("service not ready") + } + + return nil + } + + require.Eventually(t, require.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /card %}} + + +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventually(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + require.Eventually(t, require.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -797,7 +1053,7 @@ func main() { |--|--| | [`assertions.Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Eventually) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Eventually](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L119) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Eventually](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L129) {{% /tab %}} {{< /tabs >}} @@ -840,6 +1096,14 @@ errors reported on the parent t. See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for the general panic recovery semantics. +#### Synctest (opt-in) + +Wrap the condition with [WithSynctestCollect](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollect) (or [WithSynctestCollectContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollectContext)) +to run the polling loop inside a [testing/synctest] bubble, which uses +a fake clock. This eliminates timing-induced flakiness and makes the +tick count deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no +real I/O in the condition, requires [*testing.T]). + {{% expand title="Examples" %}} {{< tabs >}} {{% tab title="Usage" %}} @@ -895,6 +1159,44 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventuallyWith(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + cond := func(c *assert.CollectT) { + n := attempts.Add(1) + assert.Equal(c, int32(3), n, "not yet converged") + } + + result := assert.EventuallyWith(t, assert.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -933,6 +1235,44 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestEventuallyWith(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + cond := func(c *require.CollectT) { + n := attempts.Add(1) + require.Equal(c, int32(3), n, "not yet converged") + } + + require.EventuallyWith(t, require.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -960,11 +1300,11 @@ func main() { |--|--| | [`assertions.EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L295) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L330) {{% /tab %}} {{< /tabs >}} -### Never{#never} +### Never[C NeverConditioner] {{% icon icon="star" color=orange %}}{#neverc-neverconditioner} Never asserts that the given condition is never satisfied until timeout, periodically checking the target function at each tick. @@ -994,6 +1334,15 @@ See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Even See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) to run the polling loop inside a +[testing/synctest] bubble, which uses a fake clock. This eliminates +timing-induced flakiness and makes the tick count deterministic. See +[WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in the condition, +requires [*testing.T]). Note: [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) does not accept the context/error +form of condition, so [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext) does not apply here. + {{% expand title="Examples" %}} {{< tabs >}} {{% tab title="Usage" %}} @@ -1078,6 +1427,40 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNever(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + result := assert.Never(t, assert.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %t", result) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -1156,6 +1539,40 @@ func main() { {{% /card %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNever(t *testing.T) +package main + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + require.Never(t, require.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -1168,26 +1585,22 @@ func main() { {{% tab title="assert" style="secondary" %}} | Signature | Usage | |--|--| -| [`assert.Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) | package-level function | -| [`assert.Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Neverf) | formatted variant | -| [`assert.(*Assertions).Never(condition func() bool, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Never) | method variant | -| [`assert.(*Assertions).Neverf(condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Neverf) | method formatted variant | +| [`assert.Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) | package-level function | +| [`assert.Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Neverf) | formatted variant | {{% /tab %}} {{% tab title="require" style="secondary" %}} | Signature | Usage | |--|--| -| [`require.Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Never) | package-level function | -| [`require.Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Neverf) | formatted variant | -| [`require.(*Assertions).Never(condition func() bool, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Never) | method variant | -| [`require.(*Assertions).Neverf(condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Neverf) | method formatted variant | +| [`require.Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Never) | package-level function | +| [`require.Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Neverf) | formatted variant | {{% /tab %}} {{% tab title="internal" style="accent" icon="wrench" %}} | Signature | Usage | |--|--| -| [`assertions.Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Never) | internal implementation | +| [`assertions.Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Never) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L168) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L187) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/api/metrics.md b/docs/doc-site/api/metrics.md index ea6e2a7ca..4cd34e74a 100644 --- a/docs/doc-site/api/metrics.md +++ b/docs/doc-site/api/metrics.md @@ -17,7 +17,7 @@ Counts for core functionality, excluding variants (formatted, forward, forward-f | ------------------------ | ----------------- | | All functions | 135 | | All core assertions | 131 | -| Generic assertions | 49 | +| Generic assertions | 50 | | Helpers (not assertions) | 4 | | Others | 0 | @@ -45,7 +45,7 @@ Table of core assertions, excluding variants. Each function is side by side with | [ErrorContains](error/#errorcontains) | | error | | | [ErrorIs](error/#erroris) | [NotErrorIs](error/#noterroris) | error | | | [EventuallyWith[C CollectibleConditioner]](condition/#eventuallywithc-collectibleconditioner) {{% icon icon="star" color=orange %}} | | condition | | -| [Eventually[C Conditioner]](condition/#eventuallyc-conditioner) {{% icon icon="star" color=orange %}} | [Never](condition/#never) | condition | | +| [Eventually[C Conditioner]](condition/#eventuallyc-conditioner) {{% icon icon="star" color=orange %}} | [Never](condition/#neverc-neverconditioner) | condition | | | [Exactly](equality/#exactly) | | equality | | | [Fail](testing/#fail) | | testing | | | [FailNow](testing/#failnow) | | testing | | diff --git a/docs/doc-site/api/safety.md b/docs/doc-site/api/safety.md index 97c006c21..66aca29c4 100644 --- a/docs/doc-site/api/safety.md +++ b/docs/doc-site/api/safety.md @@ -174,7 +174,7 @@ func main() { |--|--| | [`assertions.NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L98) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L100) {{% /tab %}} {{< /tabs >}} @@ -429,7 +429,7 @@ func (m *mockFailNowT) Failed() bool { |--|--| | [`assertions.NoGoRoutineLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L45) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L47) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/project/maintainers/ROADMAP.md b/docs/doc-site/project/maintainers/ROADMAP.md index 08707dd85..69fb99166 100644 --- a/docs/doc-site/project/maintainers/ROADMAP.md +++ b/docs/doc-site/project/maintainers/ROADMAP.md @@ -37,7 +37,7 @@ timeline : Eventually, Eventually (with context), Consistently : Migration tool section Q2 2026 - 📝 v2.5 (May 2026) : synctest for Eventually/Consistently + 📝 v2.5 (May 2026) : synctest opt-in for Eventually/Never/Consistently/EventuallyWith (done) : NoFileDescriptorLeak (macOS, Windows) : New candidate features from upstream : export internal tools (spew, difflib) diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index 1eb2dcfeb..7f25fc213 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -214,6 +214,19 @@ See also a quick [migration guide](./MIGRATION.md). | `Consistently[C Conditioner]` | `func() bool` or `func(context.Context) error` | async assertion to express "always true" (adapted proposal [#1606], [#1087]) | | `(*CollectT).Cancel` | — | explicit escape hatch to abort an `EventuallyWith` assertion immediately (adapted proposal [#1830]) | +#### New Types — synctest opt-in (4) + +| Type | Underlying | Purpose | +|------|------------|---------| +| `WithSynctest` | `func() bool` | Opt-in wrapper for `Eventually`, `Never`, `Consistently` — runs the polling loop inside a [`testing/synctest`](https://pkg.go.dev/testing/synctest) bubble with a fake clock | +| `WithSynctestContext` | `func(context.Context) error` | Same as above, for the context/error condition form (`Eventually`, `Consistently`) | +| `WithSynctestCollect` | `func(*CollectT)` | Same as above, for `EventuallyWith` | +| `WithSynctestCollectContext` | `func(context.Context, *CollectT)` | Same as above, for the `EventuallyWith` context variant | + +**Benefits**: deterministic tick counts, zero real wall-clock time for long timeouts, elimination of timing-induced flakiness in test suites. + +**Constraints**: requires Go 1.25+; caller must pass a concrete `*testing.T`; condition must not perform real I/O and must not be started from an external goroutine driving state change via real time. See the [`WithSynctest` godoc](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) and the [Async Testing section of EXAMPLES.md](./EXAMPLES.md#deterministic-polling-with-synctest-opt-in). + [#1087]: https://github.com/stretchr/testify/issues/1087 [#1606]: https://github.com/stretchr/testify/pulls/1606 @@ -226,9 +239,11 @@ See also a quick [migration guide](./MIGRATION.md). | Unified implementation | Internal refactoring | Single implementation eliminates code duplication and prevents resource leaks | | `func(context.Context) error` conditions | extensions to the async domain | control over context allows for more complex cases to be supported | | Type parameter | Internal refactoring | `Eventually` now accepts several signatures for its condition and uses a type parameter (non-breaking) | +| `Never[C NeverConditioner]` now generic | synctest opt-in | `Never` gained a narrow type constraint (`func() bool | WithSynctest`) to accept the synctest wrapper. Existing call sites are unaffected; only code that stores `Never` in a function value of type `func(T, func() bool, ...)` needs to specify `Never[func() bool]` (non-breaking for typical use) | | `CollectT.FailNow` now per-tick | aligns with [stretchr/testify] semantics and [#1819] | `FailNow` aborts the current tick only; the poller retries on the next tick. This makes `require`-style assertions inside `EventuallyWith` behave naturally (was: cancel the whole assertion immediately) | | New `CollectT.Cancel` | implements [#1830] | Explicit escape hatch to abort the whole `EventuallyWith` assertion immediately, cancelling the polling context before exiting via `runtime.Goexit` | | Per-tick goroutine wrap | implements [#1819] | The condition function is evaluated in its own goroutine so that `runtime.Goexit` (including transitively via `require`) only aborts the current tick and not the surrounding poll loop | +| Channels now bubble-owned | synctest opt-in | `conditionChan` and `doneChan` moved from `newConditionPoller` into `pollCondition` so that they are created inside a synctest bubble when the caller opts in. This is a correctness fix for synctest activation AND a general lifecycle cleanup (per-call, not per-struct). | **Impact**: This fix eliminates goroutine leaks that could occur when using `Eventually` or `Never` assertions. The new implementation uses a context-based approach that properly manages resources and provides a cleaner shutdown mechanism. Callers should **NOT** assume that the call to `Eventually` or `Never` exits before the condition is evaluated. diff --git a/docs/doc-site/usage/EXAMPLES.md b/docs/doc-site/usage/EXAMPLES.md index 729a540f7..954198ba8 100644 --- a/docs/doc-site/usage/EXAMPLES.md +++ b/docs/doc-site/usage/EXAMPLES.md @@ -780,6 +780,83 @@ func TestEventuallyWithCancel(t *testing.T) { 4. Use `Eventually` for simple boolean conditions (faster, simpler) 5. Use `Never` to verify invariants over time (no race conditions, no invalid state) +#### Deterministic polling with synctest (opt-in) + +All four async assertions (`Eventually`, `Never`, `Consistently`, `EventuallyWith`) +accept an **opt-in wrapper** that runs the polling loop inside a [testing/synctest] +bubble. Inside the bubble, `time.Ticker`, `time.After`, and `context.WithTimeout` +use a **fake clock** that advances only when all goroutines are durably blocked — +so the tick count is deterministic and long timeouts cost zero real wall-clock time. + +Wrappers: + +| Wrapper | Underlying condition form | Used by | +|---------|---------------------------|---------| +| `WithSynctest` | `func() bool` | `Eventually`, `Never`, `Consistently` | +| `WithSynctestContext` | `func(context.Context) error` | `Eventually`, `Consistently` | +| `WithSynctestCollect` | `func(*CollectT)` | `EventuallyWith` | +| `WithSynctestCollectContext` | `func(context.Context, *CollectT)` | `EventuallyWith` | + +Minimal example: + +```go +import ( + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestDeterministicPolling(t *testing.T) { + attempts := 0 + cond := func() bool { + attempts++ + return attempts == 5 // converges on the 5th tick + } + + // 1-hour timeout with 1-minute tick completes in microseconds of real + // wall-clock time under the fake clock. Exactly 5 calls to the condition. + assert.Eventually(t, assert.WithSynctest(cond), 1*time.Hour, 1*time.Minute) +} +``` + +{{% notice info %}} +**When to use synctest wrappers:** the condition is pure compute, or uses +`time.Sleep`/timers/tickers/channels created inside the condition. These +are ideal for deterministic tests of retry logic and polling loops. +{{% /notice %}} + +{{% notice warning %}} +**When NOT to use them:** + +- The condition performs real I/O (network, filesystem, syscalls): those + block goroutines non-durably, so the fake clock stalls and the timeout + may not fire. +- External goroutines drive state change via real time (e.g. a + `go func() { time.Sleep(...); flag = true }()` started *before* the + assertion call): the external goroutine runs on real time, while the + bubble advances fake time independently. +- The test body is already running inside a `synctest.Test` bubble: + nested bubbles are forbidden and will panic. In that case, just use + the plain condition form — the outer bubble already gives you fake time. +- The caller is not a `*testing.T` (e.g. a mock): activation requires a + concrete `*testing.T`; with other `T` implementations, the wrapper + silently falls back to real-time polling. +{{% /notice %}} + +Shared state between the condition and enclosing scope (counters, atomics, +flags) works as expected. Polling is already serialized (see **Concurrency** +in the `Eventually` godoc), so no additional synchronization is required +inside the condition. + +See also the testable examples: + +- [`ExampleWithSynctest_asyncReady`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#example-package-WithSynctest-asyncReady) +- [`ExampleWithSynctestContext_healthCheck`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#example-package-WithSynctestContext-healthCheck) +- [`ExampleWithSynctestCollect_convergence`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#example-package-WithSynctestCollect-convergence) + +[testing/synctest]: https://pkg.go.dev/testing/synctest + ### Goroutine Leak Detection Use `NoGoRoutineLeak` to verify that your code doesn't leak goroutines. This is critical for long-running applications, connection pools, and worker patterns. diff --git a/hack/doc-site/hugo/layouts/partials/custom-header.html b/hack/doc-site/hugo/layouts/partials/custom-header.html index b7db09ed1..50cd9ae33 100644 --- a/hack/doc-site/hugo/layouts/partials/custom-header.html +++ b/hack/doc-site/hugo/layouts/partials/custom-header.html @@ -15,4 +15,16 @@ /* Auto-adapt to 2 columns when a container has exactly 2 cards. */ grid-template-columns: repeat(2, 1fr); } +.card-container .card { + /* Relearn's theme.css caps cards at max-height: 600px with overflow: hidden, + which truncates long testable examples. Let them grow vertically and + scroll when needed. */ + max-height: none; + overflow-y: auto; +} +.card-container .card pre { + /* Ensure horizontal scrolling on long lines inside code blocks. */ + overflow-x: auto; + white-space: pre; +} diff --git a/hack/doc-site/hugo/metrics.yaml b/hack/doc-site/hugo/metrics.yaml index d4a0d3dca..417ce8e6c 100644 --- a/hack/doc-site/hugo/metrics.yaml +++ b/hack/doc-site/hugo/metrics.yaml @@ -3,8 +3,8 @@ params: domains: 19 functions: 135 assertions: 131 - generics: 49 - nongeneric_assertions: 86 + generics: 50 + nongeneric_assertions: 85 helpers: 4 others: 0 by_domain: diff --git a/internal/assertions/condition.go b/internal/assertions/condition.go index 2d3961c03..13aab6b0d 100644 --- a/internal/assertions/condition.go +++ b/internal/assertions/condition.go @@ -10,6 +10,8 @@ import ( "runtime" "sync" "sync/atomic" + "testing" + "testing/synctest" "time" ) @@ -112,6 +114,14 @@ func Condition(t T, comp func() bool, msgAndArgs ...any) bool { // To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << // timeout). // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires `*testing.T`). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -161,11 +171,20 @@ func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] to run the polling loop inside a +// [testing/synctest] bubble, which uses a fake clock. This eliminates +// timing-induced flakiness and makes the tick count deterministic. See +// [WithSynctest] for the constraints (no real I/O in the condition, +// requires [*testing.T]). Note: [Never] does not accept the context/error +// form of condition, so [WithSynctestContext] does not apply here. +// // # Examples // // success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond -func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { // Domain: condition if h, ok := t.(H); ok { h.Helper() @@ -219,6 +238,14 @@ func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires [*testing.T]). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -287,6 +314,14 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t // // See [Eventually] for the general panic recovery semantics. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctestCollect] (or [WithSynctestCollectContext]) +// to run the polling loop inside a [testing/synctest] bubble, which uses +// a fake clock. This eliminates timing-induced flakiness and makes the +// tick count deterministic. See [WithSynctest] for the constraints (no +// real I/O in the condition, requires [*testing.T]). +// // # Examples // // success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond @@ -306,25 +341,27 @@ func eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim h.Helper() } + wantsBubble, cond := makeCondition(condition, false) p := newConditionPoller(pollOptions{ mode: pollUntilTrue, failMessage: "condition never satisfied", }) - return p.pollCondition(t, makeCondition(condition, false), timeout, tick, msgAndArgs...) + return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) } -func never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } + wantsBubble, cond := makeCondition(condition, true) p := newConditionPoller(pollOptions{ mode: pollUntilTimeout, failMessage: "condition satisfied", }) - return p.pollCondition(t, makeCondition(condition, true), timeout, tick, msgAndArgs...) + return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) } func consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { @@ -332,12 +369,13 @@ func consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t h.Helper() } + wantsBubble, cond := makeCondition(condition, false) p := newConditionPoller(pollOptions{ mode: pollUntilTimeout, failMessage: "condition failed once", }) - return p.pollCondition(t, makeCondition(condition, false), timeout, tick, msgAndArgs...) + return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) } func eventuallyWithT[C CollectibleConditioner](t T, collectCondition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { @@ -347,7 +385,7 @@ func eventuallyWithT[C CollectibleConditioner](t T, collectCondition C, timeout var lastCollectedErrors []error var cancelFunc func() // will be set by pollCondition via onSetup - fn := makeCollectibleCondition(collectCondition) + wantsBubble, fn := makeCollectibleCondition(collectCondition) condition := func(ctx context.Context) (err error) { collector := new(CollectT).withCancelFunc(cancelFunc) @@ -381,16 +419,51 @@ func eventuallyWithT[C CollectibleConditioner](t T, collectCondition C, timeout onSetup: func(cancel func()) { cancelFunc = cancel }, }) - return p.pollCondition(t, condition, timeout, tick, msgAndArgs...) + return runPoller(t, p, condition, timeout, tick, wantsBubble, msgAndArgs...) } -func makeCondition[C Conditioner](condition C, reverse bool) func(context.Context) error { - fn := any(condition) +// runPoller dispatches the polling to either the real-time or the +// [synctest] bubble-wrapped path, based on whether the condition opted into +// fake time AND the caller passed a concrete [*testing.T]. +// +// When `wantsBubble` is true but `t` is not a `*testing.T` (e.g. a mock or +// [CollectT]), the call silently falls back to real-time polling. The +// synctest bubble requires a real `*testing.T`. +func runPoller(t T, p *conditionPoller, cond func(context.Context) error, timeout, tick time.Duration, wantsBubble bool, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } - switch typed := fn.(type) { + testingT, canBubble := t.(*testing.T) + if !wantsBubble || !canBubble { + return p.pollCondition(t, cond, timeout, tick, msgAndArgs...) + } + + var result bool + synctest.Test(testingT, func(inner *testing.T) { + result = p.pollCondition(inner, cond, timeout, tick, msgAndArgs...) + }) + + return result +} + +// makeCondition normalizes any variant from [Conditioner] or [NeverConditioner] +// into the unified `func(context.Context) error` form used by [pollCondition], +// and reports whether the caller opted into synctest-bubble polling. +// +// [WithSynctest] and [WithSynctestContext] are recognized as their underlying +// `func() bool` and `func(context.Context) error` forms with `wantsBubble = true`. +func makeCondition(condition any, reverse bool) (wantsBubble bool, cond func(context.Context) error) { + switch typed := condition.(type) { + case WithSynctest: + _, cond = makeCondition((func() bool)(typed), reverse) + return true, cond + case WithSynctestContext: + _, cond = makeCondition((func(context.Context) error)(typed), reverse) + return true, cond case func() bool: if !reverse { - return func(ctx context.Context) error { + return false, func(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() @@ -405,7 +478,7 @@ func makeCondition[C Conditioner](condition C, reverse bool) func(context.Contex } // inverse bool <-> error logic for Never - return func(ctx context.Context) error { + return false, func(ctx context.Context) error { select { case <-ctx.Done(): return nil @@ -421,18 +494,25 @@ func makeCondition[C Conditioner](condition C, reverse bool) func(context.Contex // No reversal needed: the poller already uses err != nil as "condition happened". // For Eventually: err == nil = success. For Never: err != nil = failure. // Both align with the natural error semantics without inversion. - return typed + return false, typed default: // unreachable panic(fmt.Errorf("unsupported Conditioner type. Mismatch with type constraint: %T", condition)) } } -func makeCollectibleCondition[C CollectibleConditioner](condition C) func(context.Context, *CollectT) { - fn := any(condition) - - switch typed := fn.(type) { +// makeCollectibleCondition normalizes any [CollectibleConditioner] variant +// into the unified `func(context.Context, *CollectT)` form, and reports +// whether the caller opted into synctest-bubble polling. +func makeCollectibleCondition(condition any) (wantsBubble bool, fn func(context.Context, *CollectT)) { + switch typed := condition.(type) { + case WithSynctestCollect: + _, fn = makeCollectibleCondition((func(*CollectT))(typed)) + return true, fn + case WithSynctestCollectContext: + _, fn = makeCollectibleCondition((func(context.Context, *CollectT))(typed)) + return true, fn case func(*CollectT): - return func(ctx context.Context, collector *CollectT) { + return false, func(ctx context.Context, collector *CollectT) { select { case <-ctx.Done(): collector.Errorf("%v", ctx.Err()) @@ -441,7 +521,7 @@ func makeCollectibleCondition[C CollectibleConditioner](condition C) func(contex } } case func(context.Context, *CollectT): - return typed + return false, typed default: // unreachable panic(fmt.Errorf("unsupported CollectibleConditioner type. Mismatch with type constraint: %T", condition)) } @@ -470,12 +550,19 @@ type conditionPoller struct { func newConditionPoller(o pollOptions) *conditionPoller { return &conditionPoller{ - pollOptions: o, - conditionChan: make(chan func(context.Context) error, 1), - doneChan: make(chan struct{}), + pollOptions: o, } } +// initChannels creates the polling channels. MUST be called from inside +// [pollCondition] so that — when the caller activated a [synctest] bubble +// — the channels are bubble-owned. Receives on channels created outside +// the bubble do NOT count as durably blocking, which stalls the fake clock. +func (p *conditionPoller) initChannels() { + p.conditionChan = make(chan func(context.Context) error, 1) + p.doneChan = make(chan struct{}) +} + // pollMode determines how the condition polling should behave. type pollMode int @@ -515,6 +602,12 @@ func (p *conditionPoller) pollCondition(t T, condition func(context.Context) err condition = recoverCondition(condition) + // Channels and ticker MUST be created inside pollCondition so that, + // when the caller activated a synctest bubble, they are bubble-owned + // primitives. Channels created outside the bubble do not count as + // durably blocking and would stall the fake clock. + p.initChannels() + p.ticker = time.NewTicker(tick) defer p.ticker.Stop() diff --git a/internal/assertions/condition_synctest_test.go b/internal/assertions/condition_synctest_test.go new file mode 100644 index 000000000..4c01a6c7d --- /dev/null +++ b/internal/assertions/condition_synctest_test.go @@ -0,0 +1,910 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package assertions + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "testing/synctest" + "time" +) + +// =========================================================================== +// Dual-path test runner. +// =========================================================================== + +// runDualPath runs fn twice: once with real time, once inside a synctest +// bubble. fn should use plain (non-wrapped) conditions and a mock T so +// that failures can be verified without polluting the outer test. +// +// NOTE: fn MUST NOT call t.Parallel(). [synctest.Test] forbids t.Parallel() +// inside a bubble. This restriction is transparent to real users of the +// async assertions — users do not call t.Parallel() inside condition +// functions. +func runDualPath(t *testing.T, name string, fn func(t *testing.T)) { + t.Helper() + t.Run(name+"/real-time", fn) + t.Run(name+"/synctest", func(t *testing.T) { + synctest.Test(t, fn) + }) +} + +// =========================================================================== +// Dual-path tests. +// =========================================================================== + +// TestConditionDualPath_EventuallyBehavior exercises [Eventually]'s core +// behavior through both real-time and bubble-wrapped test harnesses. +// Using the harness-level bubble means the mock captures failures even +// under fake time — the best of both worlds for behavior parity tests. +func TestConditionDualPath_EventuallyBehavior(t *testing.T) { + runDualPath(t, "succeeds on first call", func(t *testing.T) { + mock := new(errorsCapturingT) + if !Eventually(mock, func() bool { return true }, testTimeout, testTick) { + t.Error("expected success") + } + if len(mock.errors) != 0 { + t.Errorf("expected no errors, got %d", len(mock.errors)) + } + }) + + runDualPath(t, "fails on persistent false", func(t *testing.T) { + mock := new(errorsCapturingT) + if Eventually(mock, func() bool { return false }, testTimeout, testTick) { + t.Error("expected failure") + } + if len(mock.errors) == 0 { + t.Error("expected mock to capture at least one error") + } + }) + + runDualPath(t, "succeeds after a few ticks", func(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + cond := func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return counter >= 3 + } + + if !Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected success") + } + mu.Lock() + got := counter + mu.Unlock() + if got < 3 { + t.Errorf("expected at least 3 calls, got %d", got) + } + }) +} + +// TestConditionDualPath_EventuallyWithErrorBehavior exercises the +// context/error-returning variant of [Eventually] through both paths. +func TestConditionDualPath_EventuallyWithErrorBehavior(t *testing.T) { + runDualPath(t, "succeeds after returning transient errors", func(t *testing.T) { + mock := new(errorsCapturingT) + state := 0 + cond := func(_ context.Context) error { + defer func() { state++ }() + if state < 2 { + return errors.New("not ready yet") + } + + return nil + } + if !Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected Eventually to return true") + } + }) + + runDualPath(t, "fails on persistent error", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(_ context.Context) error { + return errors.New("persistent error") + } + if Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected Eventually to return false") + } + }) + + runDualPath(t, "receives non-nil context", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(ctx context.Context) error { + if ctx == nil { + return errors.New("expected non-nil context") + } + + return nil + } + if !Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected Eventually to return true") + } + }) +} + +// TestConditionDualPath_ConsistentlyWithErrorBehavior exercises the +// context/error-returning variant of [Consistently] through both paths. +func TestConditionDualPath_ConsistentlyWithErrorBehavior(t *testing.T) { + runDualPath(t, "succeeds when always nil", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(_ context.Context) error { return nil } + if !Consistently(mock, cond, testTimeout, testTick) { + t.Error("expected Consistently to return true") + } + }) + + runDualPath(t, "fails on persistent error", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(_ context.Context) error { + return errors.New("something went wrong") + } + if Consistently(mock, cond, testTimeout, testTick) { + t.Error("expected Consistently to return false") + } + }) + + runDualPath(t, "fails when error appears on second call", func(t *testing.T) { + mock := new(errorsCapturingT) + // Channel created inside fn — under synctest, it is bubble-owned. + returns := make(chan error, 2) + returns <- nil + returns <- errors.New("something went wrong") + defer close(returns) + + cond := func(_ context.Context) error { return <-returns } + if Consistently(mock, cond, testTimeout, testTick) { + t.Error("expected Consistently to return false") + } + }) +} + +// TestConditionDualPath_NeverBehavior exercises [Never] through both paths. +func TestConditionDualPath_NeverBehavior(t *testing.T) { + runDualPath(t, "succeeds when condition never true", func(t *testing.T) { + mock := new(errorsCapturingT) + if !Never(mock, func() bool { return false }, testTimeout, testTick) { + t.Error("expected Never to return true") + } + }) + + runDualPath(t, "fails when condition becomes true", func(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + cond := func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return counter == 2 + } + + if Never(mock, cond, testTimeout, testTick) { + t.Error("expected Never to return false") + } + }) +} + +// TestConditionDualPath_ConsistentlyBehavior exercises [Consistently] through both paths. +func TestConditionDualPath_ConsistentlyBehavior(t *testing.T) { + runDualPath(t, "succeeds when condition always true", func(t *testing.T) { + mock := new(errorsCapturingT) + if !Consistently(mock, func() bool { return true }, testTimeout, testTick) { + t.Error("expected Consistently to return true") + } + }) + + runDualPath(t, "fails when condition becomes false", func(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + cond := func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return counter < 3 + } + + if Consistently(mock, cond, testTimeout, testTick) { + t.Error("expected Consistently to return false") + } + }) +} + +// TestConditionDualPath_EventuallyWithBehavior exercises [EventuallyWith] through both paths. +func TestConditionDualPath_EventuallyWithBehavior(t *testing.T) { + runDualPath(t, "succeeds when no errors collected", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(_ *CollectT) {} + if !EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return true") + } + }) + + runDualPath(t, "fails when errors persistently collected", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(c *CollectT) { Fail(c, "boom") } + if EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + }) +} + +// TestConditionDualPath_EventuallyWithContextBehavior exercises the +// context variant of [EventuallyWith] (`func(ctx, *CollectT)`) through +// both paths. +func TestConditionDualPath_EventuallyWithContextBehavior(t *testing.T) { + runDualPath(t, "succeeds after a few calls via context variant", func(t *testing.T) { + mock := new(errorsCapturingT) + counter := 0 + cond := func(_ context.Context, c *CollectT) { + counter++ + True(c, counter == 2) + } + if !EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return true") + } + if len(mock.errors) != 0 { + t.Errorf("expected 0 errors, got %d", len(mock.errors)) + } + if counter != 2 { + t.Errorf("expected exactly 2 calls, got %d", counter) + } + }) + + runDualPath(t, "fails on persistent collected failure via context variant", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(_ context.Context, c *CollectT) { + Fail(c, "fixed failure") + } + if EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + }) + + runDualPath(t, "receives non-nil context", func(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(ctx context.Context, c *CollectT) { + if ctx == nil { + Fail(c, "expected non-nil context") + } + } + if !EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return true") + } + }) +} + +// TestConditionDualPath_EventuallySucceedQuickly verifies that Eventually +// checks the condition BEFORE the first tick — by using a tick longer than +// the total duration, only the initial-check path can succeed. +func TestConditionDualPath_EventuallySucceedQuickly(t *testing.T) { + t.Parallel() + runDualPath(t, "should succeed before the first tick", dualEventuallySucceedBeforeFirstTick) +} + +func dualEventuallySucceedBeforeFirstTick(t *testing.T) { + mock := new(errorsCapturingT) + cond := func() bool { return true } + + // Tick longer than the total duration: only the initial check can succeed. + if !Eventually(mock, cond, testTimeout, 1*time.Second) { + t.Error("expected Eventually to return true before first tick") + } +} + +// TestConditionDualPath_EventuallyTimeoutBehavior verifies that Eventually +// fails correctly when the condition is slower than the timeout (issue 805) +// and when the parent context is cancelled. Both subtests run under real +// time and inside a synctest bubble — reassurance that no semantic shift +// occurs when switching modes. +func TestConditionDualPath_EventuallyTimeoutBehavior(t *testing.T) { + t.Parallel() + runDualPath(t, "should fail on timeout", dualEventuallyTimeoutOnSlowCondition) + runDualPath(t, "should fail when parent context is cancelled", dualEventuallyTimeoutOnParentCancellation) +} + +func dualEventuallyTimeoutOnSlowCondition(t *testing.T) { + mock := new(errorsCapturingT) + // Condition returns long after the Eventually timeout. + cond := func() bool { + time.Sleep(100 * time.Millisecond) + + return true + } + + if Eventually(mock, cond, time.Millisecond, time.Microsecond) { + t.Error("expected Eventually to return false on timeout") + } +} + +func dualEventuallyTimeoutOnParentCancellation(t *testing.T) { + parentCtx, failParent := context.WithCancel(context.WithoutCancel(t.Context())) + mock := new(errorsCapturingT).WithContext(parentCtx) + + cond := func() bool { + time.Sleep(testTick) + failParent() // cancels the parent context mid-assertion + time.Sleep(2 * testTick) + + return true + } + + if Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected Eventually to return false when parent context is cancelled") + } + + // Flattened from the original nested t.Run: nested subtests are forbidden + // inside a synctest bubble. These assertions verify that the reported + // errors include both the context-cancellation cause and the + // "never satisfied" marker. + if len(mock.errors) != 2 { + t.Errorf("expected 2 error messages (1 for context canceled, 1 for never satisfied), got %d", len(mock.errors)) + } + var hasContextCancelled, hasFailedCondition bool + for _, err := range mock.errors { + msg := err.Error() + switch { + case strings.Contains(msg, "context canceled"): + hasContextCancelled = true + case strings.Contains(msg, "never satisfied"): + hasFailedCondition = true + } + } + if !hasContextCancelled { + t.Error("expected a context cancelled error") + } + if !hasFailedCondition { + t.Error("expected a condition never satisfied error") + } +} + +// TestConditionDualPath_PollUntilTimeoutBehavior exercises the shared +// poll-until-timeout subtests for [Never] and [Consistently] through both +// real-time and synctest paths. These subtests cover timing-independent +// invariants (initial-check before first tick, flipped-condition failure, +// parent-context cancellation) and benefit from dual-path for determinism. +func TestConditionDualPath_PollUntilTimeoutBehavior(t *testing.T) { + t.Parallel() + for c := range pollUntilTimeoutCases() { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + runDualPath(t, "succeed with constant good value", dualPollCaseConstantGood(c)) + runDualPath(t, "succeed on timeout with slow bad value", dualPollCaseSlowBad(c)) + runDualPath(t, "fail when condition flips on second call", dualPollCaseFlipOnSecond(c)) + runDualPath(t, "fail before first tick with constant bad value", dualPollCaseBadBeforeFirstTick(c)) + runDualPath(t, "fail when parent context is cancelled", dualPollCaseParentCancelled(c)) + }) + } +} + +// --------------------------------------------------------------------------- +// Dual-path subtest bodies for PollUntilTimeout (Never / Consistently). +// --------------------------------------------------------------------------- + +func dualPollCaseConstantGood(c pollUntilTimeoutCase) func(*testing.T) { + return func(t *testing.T) { + mock := new(errorsCapturingT) + if !c.assertion(mock, func() bool { return c.goodValue }, testTimeout, testTick) { + t.Errorf("expected %s to return true", c.name) + } + } +} + +func dualPollCaseSlowBad(c pollUntilTimeoutCase) func(*testing.T) { + return func(t *testing.T) { + mock := new(errorsCapturingT) + badValue := !c.goodValue + cond := func() bool { + time.Sleep(2 * testTick) + + return badValue // returns bad value, but only after timeout + } + if !c.assertion(mock, cond, testTick, 1*time.Millisecond) { + t.Errorf("expected %s to return true on timeout", c.name) + } + } +} + +func dualPollCaseFlipOnSecond(c pollUntilTimeoutCase) func(*testing.T) { + return func(t *testing.T) { + mock := new(errorsCapturingT) + // Channel created inside fn — bubble-owned under synctest. + badValue := !c.goodValue + returns := make(chan bool, 2) + returns <- c.goodValue + returns <- badValue + defer close(returns) + + cond := func() bool { return <-returns } + if c.assertion(mock, cond, testTimeout, testTick) { + t.Errorf("expected %s to return false", c.name) + } + } +} + +func dualPollCaseBadBeforeFirstTick(c pollUntilTimeoutCase) func(*testing.T) { + return func(t *testing.T) { + mock := new(errorsCapturingT) + badValue := !c.goodValue + // Tick longer than total duration: the initial-check path must detect + // the bad value before any tick elapses. + if c.assertion(mock, func() bool { return badValue }, testTimeout, time.Second) { + t.Errorf("expected %s to return false", c.name) + } + } +} + +func dualPollCaseParentCancelled(c pollUntilTimeoutCase) func(*testing.T) { + return func(t *testing.T) { + parentCtx, failParent := context.WithCancel(context.WithoutCancel(t.Context())) + mock := new(errorsCapturingT).WithContext(parentCtx) + cond := func() bool { + failParent() // cancels the parent context + + return c.goodValue + } + if c.assertion(mock, cond, testTimeout, time.Second) { + t.Errorf("expected %s to return false when parent context is cancelled", c.name) + } + } +} + +// TestConditionDualPath_EventuallyWithCollectBehavior exercises the +// behavioral subtests of [EventuallyWith] through both paths. The +// [CollectT]-based invariants (FailNow retries, Cancel short-circuits, +// initial-check before first tick, etc.) are independent of timing and +// work identically under real time and fake time. +// +// The nanosecond-tick "race trigger" subtest of [EventuallyWith] is NOT +// migrated here — see [TestConditionEventuallyWith] for the rationale. +func TestConditionDualPath_EventuallyWithCollectBehavior(t *testing.T) { + runDualPath(t, "should complete with false (tolerant count)", dualEventuallyWithCompleteFalse) + runDualPath(t, "should complete with true", dualEventuallyWithCompleteTrue) + runDualPath(t, "should complete with fail, with latest failed condition", dualEventuallyWithFailLatest) + runDualPath(t, "should complete with success, with the ticker never used", dualEventuallyWithCompleteSuccess) + runDualPath(t, "collect.FailNow only fails the current tick (poller retries)", dualEventuallyWithFailNowRetries) + runDualPath(t, "collect.FailNow allows convergence on a later tick", dualEventuallyWithFailNowConverges) + runDualPath(t, "collect.Cancel aborts the whole assertion immediately", dualEventuallyWithCancelAborts) + runDualPath(t, "collect.Cancelf aborts with a custom message", dualEventuallyWithCancelfAborts) +} + +// --------------------------------------------------------------------------- +// Dual-path subtest bodies for EventuallyWith. +// +// These are invoked by runDualPath — both under real time and inside a +// synctest bubble. They MUST NOT call t.Parallel(): synctest forbids it. +// --------------------------------------------------------------------------- + +func dualEventuallyWithCompleteFalse(t *testing.T) { + mock := new(errorsCapturingT) + counter := 0 + cond := func(c *CollectT) { + counter++ + Fail(c, "condition fixed failure") + Fail(c, "another condition fixed failure") + } + + if EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + + // Real-time path has scheduler jitter; synctest path is exact. + // Both paths fall within this tolerance. + const expectedErrors = 4 + if len(mock.errors) < expectedErrors-1 || len(mock.errors) > expectedErrors { + t.Errorf("expected %d errors (2 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) + } + + expectedCalls := int(testTimeout / testTick) + if counter < expectedCalls-1 || counter > expectedCalls+1 { + t.Errorf("expected %d calls, got %d", expectedCalls, counter) + } +} + +func dualEventuallyWithCompleteTrue(t *testing.T) { + mock := new(errorsCapturingT) + counter := 0 + cond := func(c *CollectT) { + counter++ + True(c, counter == 2) + } + + if !EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return true") + } + if len(mock.errors) != 0 { + t.Errorf("expected 0 errors, got %d", len(mock.errors)) + } + if counter != 2 { + t.Errorf("expected condition to be called 2 times, got %d", counter) + } +} + +func dualEventuallyWithFailLatest(t *testing.T) { + mock := new(errorsCapturingT) + // Channel created inside fn — bubble-owned under synctest. + mustSleep := make(chan bool, 2) + mustSleep <- false + mustSleep <- true + close(mustSleep) + + cond := func(c *CollectT) { + if <-mustSleep { + // Ensure the second condition runs longer than the timeout. + // Real time: 1s real sleep. Fake time: 1s fake sleep. + time.Sleep(time.Second) + + return + } + Fail(c, "condition fixed failure") + } + + if EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + const expectedErrors = 3 + if len(mock.errors) != expectedErrors { + t.Errorf("expected %d errors (1 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) + } +} + +func dualEventuallyWithCompleteSuccess(t *testing.T) { + mock := new(errorsCapturingT) + cond := func(*CollectT) {} + + // Tick longer than total duration: the initial-check path must succeed. + if !EventuallyWith(mock, cond, testTimeout, time.Second) { + t.Error("expected EventuallyWith to return true") + } +} + +func dualEventuallyWithFailNowRetries(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + cond := func(c *CollectT) { + mu.Lock() + counter++ + mu.Unlock() + c.FailNow() + } + + if EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + mu.Lock() + got := counter + mu.Unlock() + if got < 2 { + t.Errorf("expected the condition to be retried multiple times, got %d call(s)", got) + } +} + +func dualEventuallyWithFailNowConverges(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + cond := func(c *CollectT) { + mu.Lock() + counter++ + n := counter + mu.Unlock() + if n < 3 { + c.FailNow() + } + } + + if !EventuallyWith(mock, cond, testTimeout, testTick) { + t.Error("expected EventuallyWith to eventually return true") + } + if len(mock.errors) != 0 { + t.Errorf("expected no errors reported on parent t after success, got %d: %v", len(mock.errors), mock.errors) + } +} + +func dualEventuallyWithCancelAborts(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + // The 30-minute timeout must NOT be waited on: in the real-time path + // Cancel must short-circuit quickly; in the synctest path the fake + // timeout costs zero real time anyway. + cond := func(c *CollectT) { + mu.Lock() + counter++ + mu.Unlock() + c.Cancel() + } + + start := time.Now() + if EventuallyWith(mock, cond, 30*time.Minute, testTick) { + t.Error("expected EventuallyWith to return false") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("expected Cancel to short-circuit, but EventuallyWith took %s", elapsed) + } + mu.Lock() + got := counter + mu.Unlock() + if got != 1 { + t.Errorf("expected the condition to be called only once, got: %d", got) + } + if len(mock.errors) == 0 { + t.Error("expected at least one error reported on parent t after Cancel") + } +} + +func dualEventuallyWithCancelfAborts(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + cond := func(c *CollectT) { + mu.Lock() + counter++ + mu.Unlock() + c.Cancelf("upstream %s is gone", "service-x") + } + + start := time.Now() + if EventuallyWith(mock, cond, 30*time.Minute, testTick) { + t.Error("expected EventuallyWith to return false") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("expected Cancelf to short-circuit, but EventuallyWith took %s", elapsed) + } + mu.Lock() + got := counter + mu.Unlock() + if got != 1 { + t.Errorf("expected the condition to be called once, got %d", got) + } + + foundCustom := false + for _, err := range mock.errors { + if strings.Contains(err.Error(), "upstream service-x is gone") { + foundCustom = true + + break + } + } + if !foundCustom { + t.Errorf("expected custom Cancelf message in errors, got: %v", mock.errors) + } +} + +// =========================================================================== +// API-level tests — verify the WithSynctest wrapper types activate the +// internal bubble when t is a concrete *testing.T. +// =========================================================================== + +// TestSynctest_EventuallyDeterministicCount verifies that the WithSynctest +// wrapper activates a bubble, enabling exact tick counts under fake time. +func TestSynctest_EventuallyDeterministicCount(t *testing.T) { + var counter int + var mu sync.Mutex + const target = 3 + + cond := WithSynctest(func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return counter == target + }) + + if !Eventually(t, cond, testTimeout, testTick) { + t.Fatal("expected Eventually to succeed") + } + mu.Lock() + got := counter + mu.Unlock() + if got != target { + t.Errorf("expected exactly %d calls under fake time, got %d", target, got) + } +} + +// TestSynctest_EventuallyHugeTimeoutInstant verifies that a 1-hour timeout +// with a 1-minute tick completes in milliseconds of real wall time when +// the condition succeeds — proving fake time is used. +func TestSynctest_EventuallyHugeTimeoutInstant(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctest(func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return counter == 5 + }) + + start := time.Now() + if !Eventually(t, cond, 1*time.Hour, 1*time.Minute) { + t.Fatal("expected success") + } + if elapsed := time.Since(start); elapsed > 1*time.Second { + t.Errorf("expected fake time to be instant, took %s", elapsed) + } +} + +// TestSynctest_EventuallyContextVariant verifies the [WithSynctestContext] +// wrapper (context/error condition form). +func TestSynctest_EventuallyContextVariant(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctestContext(func(_ context.Context) error { + mu.Lock() + defer mu.Unlock() + counter++ + if counter < 3 { + return errors.New("not yet") + } + + return nil + }) + + if !Eventually(t, cond, testTimeout, testTick) { + t.Fatal("expected success") + } + mu.Lock() + got := counter + mu.Unlock() + if got != 3 { + t.Errorf("expected exactly 3 calls, got %d", got) + } +} + +// TestSynctest_NeverUsesBubble verifies [Never] activates a bubble with the +// [WithSynctest] wrapper. +func TestSynctest_NeverUsesBubble(t *testing.T) { + cond := WithSynctest(func() bool { return false }) + start := time.Now() + if !Never(t, cond, 1*time.Hour, 1*time.Minute) { + t.Fatal("expected Never to succeed") + } + if elapsed := time.Since(start); elapsed > 1*time.Second { + t.Errorf("expected fake time to be instant, took %s", elapsed) + } +} + +// TestSynctest_ConsistentlyUsesBubble verifies [Consistently] activates a bubble. +func TestSynctest_ConsistentlyUsesBubble(t *testing.T) { + cond := WithSynctest(func() bool { return true }) + start := time.Now() + if !Consistently(t, cond, 1*time.Hour, 1*time.Minute) { + t.Fatal("expected Consistently to succeed") + } + if elapsed := time.Since(start); elapsed > 1*time.Second { + t.Errorf("expected fake time to be instant, took %s", elapsed) + } +} + +// TestSynctest_EventuallyWithUsesBubble verifies [EventuallyWith] activates +// a bubble with the [WithSynctestCollect] wrapper. +func TestSynctest_EventuallyWithUsesBubble(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctestCollect(func(c *CollectT) { + mu.Lock() + counter++ + n := counter + mu.Unlock() + True(c, n >= 3) + }) + + if !EventuallyWith(t, cond, testTimeout, testTick) { + t.Fatal("expected EventuallyWith to succeed") + } + mu.Lock() + got := counter + mu.Unlock() + if got != 3 { + t.Errorf("expected exactly 3 calls, got %d", got) + } +} + +// TestSynctest_EventuallyWithContextVariant verifies the +// [WithSynctestCollectContext] wrapper. +func TestSynctest_EventuallyWithContextVariant(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctestCollectContext(func(_ context.Context, c *CollectT) { + mu.Lock() + counter++ + n := counter + mu.Unlock() + True(c, n >= 3) + }) + + if !EventuallyWith(t, cond, testTimeout, testTick) { + t.Fatal("expected EventuallyWith to succeed") + } +} + +// TestSynctest_FallbackOnMock verifies that passing a non-*testing.T mock +// with WithSynctest falls back to real-time polling (no bubble). +func TestSynctest_FallbackOnMock(t *testing.T) { + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + cond := WithSynctest(func() bool { + mu.Lock() + defer mu.Unlock() + counter++ + + return false + }) + + start := time.Now() + if Eventually(mock, cond, testTimeout, testTick) { + t.Error("expected Eventually to return false on mock") + } + elapsed := time.Since(start) + // Real-time polling: should take close to testTimeout. + if elapsed < testTimeout/2 { + t.Errorf("expected real-time polling on mock path, took only %s", elapsed) + } +} + +// TestSynctest_PanicRecovery verifies panic recovery works through the +// bubble — recoverCondition treats a panic as a failed tick, and the +// poller retries. +func TestSynctest_PanicRecovery(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctest(func() bool { + mu.Lock() + counter++ + n := counter + mu.Unlock() + if n < 3 { + panic(fmt.Sprintf("boom %d", n)) + } + + return true + }) + + if !Eventually(t, cond, testTimeout, testTick) { + t.Fatal("expected Eventually to succeed after recovering panics") + } +} + +// TestSynctest_SlowConditionNoLeak verifies no goroutine leak when the +// condition sleeps longer than the tick interval. If a goroutine leaked, +// synctest.Test would deadlock waiting for it to exit. +func TestSynctest_SlowConditionNoLeak(t *testing.T) { + var counter int + var mu sync.Mutex + cond := WithSynctest(func() bool { + mu.Lock() + counter++ + n := counter + mu.Unlock() + // time.Sleep advances fake clock when all goroutines block durably. + time.Sleep(2 * testTick) + + return n >= 2 + }) + + start := time.Now() + if !Eventually(t, cond, testTimeout, testTick) { + t.Fatal("expected success") + } + if elapsed := time.Since(start); elapsed > 1*time.Second { + t.Errorf("expected fake-time sleeps to cost no real time, took %s", elapsed) + } +} diff --git a/internal/assertions/condition_test.go b/internal/assertions/condition_test.go index 0eaf07033..39b258758 100644 --- a/internal/assertions/condition_test.go +++ b/internal/assertions/condition_test.go @@ -4,13 +4,11 @@ package assertions import ( - "context" "errors" "fmt" "iter" "slices" "sort" - "strings" "sync" "testing" "time" @@ -43,170 +41,8 @@ func TestCondition(t *testing.T) { }) } -func TestConditionEventually(t *testing.T) { - t.Parallel() - - t.Run("condition should Eventually be false", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func() bool { - return false - } - - if Eventually(mock, condition, testTimeout, testTick) { - t.Error("expected Eventually to return false") - } - }) - - t.Run("condition should Eventually be true", func(t *testing.T) { - t.Parallel() - - state := 0 - condition := func() bool { - defer func() { - state++ - }() - return state == 2 - } - - if !Eventually(t, condition, testTimeout, testTick) { - t.Error("expected Eventually to return true") - } - }) -} - -func TestConditionEventuallyWithError(t *testing.T) { - t.Parallel() - - t.Run("condition should eventually return no error", func(t *testing.T) { - t.Parallel() - - state := 0 - condition := func(_ context.Context) error { - defer func() { state++ }() - if state < 2 { - return errors.New("not ready yet") - } - - return nil - } - - if !Eventually(t, condition, testTimeout, testTick) { - t.Error("expected Eventually to return true") - } - }) - - t.Run("condition should eventually fail on persistent error", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(_ context.Context) error { - return errors.New("persistent error") - } - - if Eventually(mock, condition, testTimeout, testTick) { - t.Error("expected Eventually to return false") - } - }) - - t.Run("condition should use provided context", func(t *testing.T) { - t.Parallel() - - condition := func(ctx context.Context) error { - if ctx == nil { - return errors.New("expected non-nil context") - } - - return nil - } - - if !Eventually(t, condition, testTimeout, testTick) { - t.Error("expected Eventually to return true") - } - }) -} - -// Check that a long running condition doesn't block Eventually. -// -// See issue 805 (and its long tail of following issues). -func TestConditionEventuallyTimeout(t *testing.T) { - t.Parallel() - - t.Run("should fail on timeout", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - // A condition function that returns after the Eventually timeout - condition := func() bool { - time.Sleep(100 * time.Millisecond) - return true - } - - if Eventually(mock, condition, time.Millisecond, time.Microsecond) { - t.Error("expected Eventually to return false on timeout") - } - }) - - t.Run("should fail on parent test failed", func(t *testing.T) { - t.Parallel() - - parentCtx, failParent := context.WithCancel(context.WithoutCancel(t.Context())) - mock := new(errorsCapturingT).WithContext(parentCtx) - - condition := func() bool { - time.Sleep(testTick) - failParent() // this cancels the parent context (e.g. mocks failing the parent test) - time.Sleep(2 * testTick) - - return true - } - - if Eventually(mock, condition, testTimeout, testTick) { - t.Error("expected Eventually to return false when parent test fails") - } - - t.Run("reported errors should include the context cancellation", func(t *testing.T) { - // assert how this failure is reported - if len(mock.errors) != 2 { - t.Errorf("expected 2 error messages (1 for context canceled, 1 for never met condition), got %d", len(mock.errors)) - } - - var hasContextCancelled, hasFailedCondition bool - for _, err := range mock.errors { - msg := err.Error() - switch { - case strings.Contains(msg, "context canceled"): - hasContextCancelled = true - case strings.Contains(msg, "never satisfied"): - hasFailedCondition = true - } - } - if !hasContextCancelled { - t.Error("expected a context cancelled error") - } - if !hasFailedCondition { - t.Error("expected a condition never satisfied error") - } - }) - }) -} - -func TestConditionEventuallySucceedQuickly(t *testing.T) { - t.Parallel() - - t.Run("should succeed before the first tick", func(t *testing.T) { - mock := new(errorsCapturingT) - condition := func() bool { return true } - - // By making the tick longer than the total duration, we expect that this test would fail if - // we didn't check the condition before the first tick elapses. - if !Eventually(mock, condition, testTimeout, 1*time.Second) { - t.Error("expected Eventually to return true before first tick") - } - }) -} - +// This test is deliberately NOT dual-path: it asserts that there are no leaking go routines +// when real time tickers are used. This is naturally verified when running in a syntest bubble. func TestConditionEventuallyNoLeak(t *testing.T) { t.Parallel() @@ -295,451 +131,37 @@ func TestConditionEventuallyNoLeak(t *testing.T) { }) } +// TestConditionEventuallyWith keeps only the nanosecond-tick "race trigger" +// subtest of [EventuallyWith]. All behavior-oriented subtests have been +// migrated to [TestConditionDualPath_EventuallyWithCollectBehavior] in +// condition_synctest_test.go, where they run under both real time and a +// synctest bubble. +// +// This test is deliberately NOT dual-path: it uses a nanosecond tick +// to force real-time scheduling races between the poller, the ticker, +// and the condition goroutine. Under synctest, ticks fire deterministically +// from a fake clock — so there are no real races to exercise. Keeping this +// test real-time-only preserves its purpose as a smoke test against +// concurrency regressions that only manifest under real scheduler pressure. func TestConditionEventuallyWith(t *testing.T) { t.Parallel() - t.Run("should complete with false", testEventuallyWithShouldCompleteWithFalse()) - t.Run("should complete with true", testEventuallyWithShouldCompleteWithTrue()) - t.Run("should complete with fail, on a nanosecond tick", testEventuallyWithShouldCompleteWithFail()) - t.Run("should complete with fail, with latest failed condition", testEventuallyWithShouldCompleteWithFailLatest()) - t.Run("should complete with success, with the ticker never used", testEventuallyWithShouldCompleteWithSuccess()) - t.Run("collect.FailNow only fails the current tick (poller retries)", testEventuallyWithFailNowRetries()) - t.Run("collect.FailNow allows convergence on a later tick", testEventuallyWithFailNowConverges()) - t.Run("collect.Cancel aborts the whole assertion immediately", testEventuallyWithCancelAborts()) - t.Run("collect.Cancelf aborts with a custom message", testEventuallyWithCancelfAborts()) -} - -// ===================================================== -// Sub tests EventuallyWith -// =====================================================. -func testEventuallyWithShouldCompleteWithFalse() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - counter := 0 - condition := func(collect *CollectT) { - counter++ - Fail(collect, "condition fixed failure") - Fail(collect, "another condition fixed failure") - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false") - } - - const expectedErrors = 4 - if len(mock.errors) < expectedErrors-1 || len(mock.errors) > expectedErrors { // it may be 3 or 4, depending on how the test schedules - t.Errorf("expected %d errors (2 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) - } - - expectedCalls := int(testTimeout / testTick) - if counter < expectedCalls-1 || counter > expectedCalls+1 { // it may be 4, 5 or 6 depending on how the test schedules - t.Errorf("expected %d calls to the condition, but got %d", expectedCalls, counter) - } - } -} - -func testEventuallyWithShouldCompleteWithTrue() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - counter := 0 - condition := func(collect *CollectT) { - counter++ - True(collect, counter == 2) - } - - if !EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return true") - } - if len(mock.errors) != 0 { - t.Errorf("expected 0 errors, got %d", len(mock.errors)) - } - const expectedCalls = 2 - if expectedCalls != counter { - t.Errorf("expected condition to be called %d times, got %d", expectedCalls, counter) - } - } -} - -func testEventuallyWithShouldCompleteWithFail() func(*testing.T) { - return func(t *testing.T) { + t.Run("should complete with fail, on a nanosecond tick (real-time race trigger)", func(t *testing.T) { t.Parallel() mock := new(errorsCapturingT) - condition := func(collect *CollectT) { - Fail(collect, "condition fixed failure") + cond := func(c *CollectT) { + Fail(c, "condition fixed failure") } - // To trigger race conditions, we run EventuallyWith with a nanosecond tick. - if EventuallyWith(mock, condition, testTimeout, time.Nanosecond) { + // Nanosecond tick to provoke real-time scheduling races. + if EventuallyWith(mock, cond, testTimeout, time.Nanosecond) { t.Error("expected EventuallyWith to return false") } const expectedErrors = 3 if len(mock.errors) != expectedErrors { t.Errorf("expected %d errors (1 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) } - } -} - -func testEventuallyWithShouldCompleteWithFailLatest() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - // We'll use a channel to control whether a condition should sleep or not. - mustSleep := make(chan bool, 2) - mustSleep <- false - mustSleep <- true - close(mustSleep) - - condition := func(collect *CollectT) { - if <-mustSleep { - // Sleep to ensure that the second condition runs longer than timeout. - time.Sleep(time.Second) - return - } - - // The first condition will fail. We expect to get this error as a result. - Fail(collect, "condition fixed failure") - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false") - } - const expectedErrors = 3 - if len(mock.errors) != expectedErrors { - t.Errorf("expected %d errors (1 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) - } - } -} - -func testEventuallyWithShouldCompleteWithSuccess() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(*CollectT) {} - - // By making the tick longer than the total duration, we expect that this test would fail if - // we didn't check the condition before the first tick elapses. - if !EventuallyWith(mock, condition, testTimeout, time.Second) { - t.Error("expected EventuallyWith to return true") - } - } -} - -func testEventuallyWithFailNowRetries() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - // FailNow on every tick: the poller must keep retrying until the timeout. - condition := func(collect *CollectT) { - mu.Lock() - counter++ - mu.Unlock() - collect.FailNow() - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false") - } - mu.Lock() - got := counter - mu.Unlock() - if got < 2 { - t.Errorf("expected the condition to be retried multiple times, got %d call(s)", got) - } - } -} - -func testEventuallyWithFailNowConverges() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - // First few ticks fail via FailNow, then converge. - condition := func(collect *CollectT) { - mu.Lock() - counter++ - n := counter - mu.Unlock() - if n < 3 { - collect.FailNow() - } - } - - if !EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to eventually return true") - } - if len(mock.errors) != 0 { - t.Errorf("expected no errors reported on parent t after success, got %d: %v", len(mock.errors), mock.errors) - } - } -} - -func testEventuallyWithCancelAborts() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - // Cancel must short-circuit: a 30-minute timeout must NOT be waited on. - condition := func(collect *CollectT) { - mu.Lock() - counter++ - mu.Unlock() - collect.Cancel() - } - - start := time.Now() - if EventuallyWith(mock, condition, 30*time.Minute, testTick) { - t.Error("expected EventuallyWith to return false") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Errorf("expected Cancel to short-circuit, but EventuallyWith took %s", elapsed) - } - mu.Lock() - got := counter - mu.Unlock() - if got != 1 { - t.Errorf("expected the condition function to have been called only once, but got: %d", got) - } - if len(mock.errors) == 0 { - t.Error("expected at least one error reported on parent t after Cancel") - } - } -} - -func testEventuallyWithCancelfAborts() func(*testing.T) { - return func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - condition := func(collect *CollectT) { - mu.Lock() - counter++ - mu.Unlock() - collect.Cancelf("upstream %s is gone", "service-x") - } - - start := time.Now() - if EventuallyWith(mock, condition, 30*time.Minute, testTick) { - t.Error("expected EventuallyWith to return false") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Errorf("expected Cancelf to short-circuit, but EventuallyWith took %s", elapsed) - } - mu.Lock() - got := counter - mu.Unlock() - if got != 1 { - t.Errorf("expected the condition to be called once, got %d", got) - } - - foundCustom := false - for _, err := range mock.errors { - if strings.Contains(err.Error(), "upstream service-x is gone") { - foundCustom = true - - break - } - } - if !foundCustom { - t.Errorf("expected custom Cancelf message in errors, got: %v", mock.errors) - } - } -} - -func TestConditionPollUntilTimeout(t *testing.T) { - t.Parallel() - - for c := range pollUntilTimeoutCases() { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - - badValue := !c.goodValue - - t.Run("should succeed with constant good value", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - if !c.assertion(mock, func() bool { return c.goodValue }, testTimeout, testTick) { - t.Errorf("expected %s to return true", c.name) - } - }) - - t.Run("should succeed on timeout with slow bad value", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func() bool { - time.Sleep(2 * testTick) - return badValue // returns bad value, but only after timeout - } - - if !c.assertion(mock, condition, testTick, 1*time.Millisecond) { - t.Errorf("expected %s to return true on timeout", c.name) - } - }) - - t.Run("should fail when condition flips on second call", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - returns := make(chan bool, 2) - returns <- c.goodValue - returns <- badValue - defer close(returns) - - condition := func() bool { return <-returns } - - if c.assertion(mock, condition, testTimeout, testTick) { - t.Errorf("expected %s to return false", c.name) - } - }) - - t.Run("should fail before first tick with constant bad value", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - // By making the tick longer than the total duration, we expect that this test would fail if - // we didn't check the condition before the first tick elapses. - if c.assertion(mock, func() bool { return badValue }, testTimeout, time.Second) { - t.Errorf("expected %s to return false", c.name) - } - }) - - t.Run("should fail when parent test fails", func(t *testing.T) { - t.Parallel() - - parentCtx, failParent := context.WithCancel(context.WithoutCancel(t.Context())) - mock := new(errorsCapturingT).WithContext(parentCtx) - condition := func() bool { - failParent() // cancels the parent context - return c.goodValue - } - if c.assertion(mock, condition, testTimeout, time.Second) { - t.Errorf("expected %s to return false when parent test fails", c.name) - } - }) - }) - } -} - -func TestConditionConsistentlyWithError(t *testing.T) { - t.Parallel() - - t.Run("should succeed when condition always returns nil", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(_ context.Context) error { - return nil // no error = condition not triggered - } - - if !Consistently(mock, condition, testTimeout, testTick) { - t.Error("expected Consistently to return true when condition never returns an error") - } - }) - - t.Run("should fail when condition returns an error", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(_ context.Context) error { - return errors.New("something went wrong") - } - - if Consistently(mock, condition, testTimeout, testTick) { - t.Error("expected Consistently to return false when condition returns an error") - } - }) - - t.Run("should fail when error is returned on second call", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - returns := make(chan error, 2) - returns <- nil - returns <- errors.New("something went wrong") - defer close(returns) - - condition := func(_ context.Context) error { - return <-returns - } - - if Consistently(mock, condition, testTimeout, testTick) { - t.Error("expected Consistently to return false") - } - }) -} - -func TestConditionEventuallyWithContext(t *testing.T) { - t.Parallel() - - t.Run("should complete with true using context variant", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - counter := 0 - condition := func(_ context.Context, collect *CollectT) { - counter++ - True(collect, counter == 2) - } - - if !EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return true") - } - if len(mock.errors) != 0 { - t.Errorf("expected 0 errors, got %d", len(mock.errors)) - } - const expectedCalls = 2 - if expectedCalls != counter { - t.Errorf("expected condition to be called %d times, got %d", expectedCalls, counter) - } - }) - - t.Run("should complete with false using context variant", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(_ context.Context, collect *CollectT) { - Fail(collect, "condition fixed failure") - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false") - } - }) - - t.Run("should receive a non-nil context", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(ctx context.Context, collect *CollectT) { - if ctx == nil { - Fail(collect, "expected non-nil context") - } - } - - if !EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return true") - } }) } @@ -774,7 +196,7 @@ func pollUntilTimeoutCases() iter.Seq[pollUntilTimeoutCase] { return slices.Values([]pollUntilTimeoutCase{ { name: "Never", - assertion: Never, + assertion: Never[func() bool], goodValue: false, // Never succeeds when the condition always returns false ("never true") }, { diff --git a/internal/assertions/generics.go b/internal/assertions/generics.go index 5d8dbb3ee..439737ed1 100644 --- a/internal/assertions/generics.go +++ b/internal/assertions/generics.go @@ -67,14 +67,74 @@ type ( // Conditioner is a function used in asynchronous condition assertions. // // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + // + // The [WithSynctest] and [WithSynctestContext] wrappers opt a call into + // fake-time polling via [testing/synctest]. See [WithSynctest] for details. Conditioner interface { - func() bool | func(context.Context) error + func() bool | func(context.Context) error | WithSynctest | WithSynctestContext + } + + // NeverConditioner is a function used by [Never]. + // + // Unlike [Conditioner], [Never] does not accept the context-returning-error + // form to avoid the double-negation confusion ("never returns no error"). + // + // The [WithSynctest] wrapper opts a call into fake-time polling. + NeverConditioner interface { + func() bool | WithSynctest } // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. // // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + // + // The [WithSynctestCollect] and [WithSynctestCollectContext] wrappers opt a + // call into fake-time polling. See [WithSynctest] for details. CollectibleConditioner interface { - func(*CollectT) | func(context.Context, *CollectT) + func(*CollectT) | func(context.Context, *CollectT) | + WithSynctestCollect | WithSynctestCollectContext } + + // WithSynctest wraps a [func() bool] condition to run [Eventually] / + // [Never] / [Consistently] polling inside a [testing/synctest] bubble, + // so `time.Ticker`, `time.After`, and `context.WithTimeout` use a fake + // clock. Activation requires the caller to pass a real `*testing.T`; + // with mocks or other [T] implementations, the wrapper falls back to + // real-time polling. + // + // # When to use + // + // Use when the condition is pure compute, relies on `time.Sleep`, or + // coordinates via channels created inside the condition. Fake time + // eliminates timing-induced flakiness and enables deterministic tick + // counts. + // + // # When not to use + // + // Do NOT use when the condition performs real I/O (network, filesystem, + // syscalls): those block goroutines non-durably, so the fake clock + // stalls and the timeout may not fire. Also do NOT use inside a test + // that is already running in a [synctest.Test] bubble — nested bubbles + // are forbidden and will panic. + // + // # Shared state + // + // The condition may read and write variables captured from the enclosing + // scope; condition execution is serialized by design (see [Eventually]'s + // Concurrency section). Avoid sharing channels or mutexes with goroutines + // outside the bubble, as this will stall the fake clock. + WithSynctest func() bool + + // WithSynctestContext is the [func(context.Context) error] counterpart + // of [WithSynctest]. See [WithSynctest] for details. + WithSynctestContext func(context.Context) error + + // WithSynctestCollect is the [func(*CollectT)] counterpart of + // [WithSynctest] for use with [EventuallyWith]. See [WithSynctest] for details. + WithSynctestCollect func(*CollectT) + + // WithSynctestCollectContext is the [func(context.Context, *CollectT)] + // counterpart of [WithSynctest] for use with [EventuallyWith]. See + // [WithSynctest] for details. + WithSynctestCollectContext func(context.Context, *CollectT) ) diff --git a/require/require_adhoc_example_9_test.go b/require/require_adhoc_example_9_test.go new file mode 100644 index 000000000..1374067c0 --- /dev/null +++ b/require/require_adhoc_example_9_test.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package require_test + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +// ExampleWithSynctest_asyncReady demonstrates opting into [testing/synctest] +// bubble polling via [require.WithSynctest]. Time operations inside the bubble +// use a fake clock — a 1-hour timeout with a 1-minute tick completes in +// microseconds of real wall-clock time while remaining deterministic. +// +// Prefer this wrapper when the condition is pure compute or uses [time.Sleep] +// internally. See [require.WithSynctest] for the constraints (no real I/O, no +// external goroutines driving state change). +func ExampleEventually_withSyncTest() { + t := new(testing.T) // normally provided by test + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + require.Eventually(t, require.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", !t.Failed(), attempts.Load()) + + // Output: ready: true, attempts: 5 +} + +// ExampleWithSynctestContext_healthCheck demonstrates the context/error +// variant of the synctest opt-in. [require.WithSynctestContext] wraps a +// [func(context.Context) error] condition for fake-time polling. +func ExampleEventually_withContext() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + healthCheck := func(_ context.Context) error { + if attempts.Add(1) < 3 { + return errors.New("service not ready") + } + + return nil + } + + require.Eventually(t, require.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", !t.Failed(), attempts.Load()) + + // Output: healthy: true, attempts: 3 +} + +// ExampleWithSynctest_never demonstrates [require.Never] with the synctest +// opt-in. The condition is polled over the fake-time window without costing +// real wall-clock time. +func ExampleNever_withSyncTest() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + require.Never(t, require.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %t", !t.Failed()) + + // Output: never flipped: true +} + +// ExampleWithSynctest_consistently demonstrates [require.Consistently] with +// the synctest opt-in — asserting an invariant holds across the entire +// observation window under deterministic fake time. +func ExampleConsistently_withSynctest() { + t := new(testing.T) // normally provided by test + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + require.Consistently(t, require.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %t", !t.Failed()) + + // Output: invariant held: true +} + +// ExampleWithSynctestCollect_convergence demonstrates [require.EventuallyWith] +// with [require.WithSynctestCollect] — a [CollectT]-based condition polled +// inside a synctest bubble. Useful when the condition uses several require / +// assert calls and you want deterministic retry behavior. +func ExampleEventuallyWith_withSynctest() { + t := new(testing.T) // normally provided by test + + var attempts atomic.Int32 + cond := func(c *require.CollectT) { + n := attempts.Add(1) + require.Equal(c, int32(3), n, "not yet converged") + } + + require.EventuallyWith(t, require.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", !t.Failed(), attempts.Load()) + + // Output: converged: true, attempts: 3 +} diff --git a/require/require_assertions.go b/require/require_assertions.go index 6bc406cf5..61a50dd84 100644 --- a/require/require_assertions.go +++ b/require/require_assertions.go @@ -83,6 +83,14 @@ func Condition(t T, comp func() bool, msgAndArgs ...any) { // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires [*testing.T]). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -575,6 +583,14 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) { // To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << // timeout). // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires `*testing.T`). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond @@ -647,6 +663,14 @@ func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim // // See [Eventually] for the general panic recovery semantics. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctestCollect] (or [WithSynctestCollectContext]) +// to run the polling loop inside a [testing/synctest] bubble, which uses +// a fake clock. This eliminates timing-induced flakiness and makes the +// tick count deterministic. See [WithSynctest] for the constraints (no +// real I/O in the condition, requires [*testing.T]). +// // # Examples // // success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond @@ -2236,17 +2260,26 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // // See [Eventually]. // +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] to run the polling loop inside a +// [testing/synctest] bubble, which uses a fake clock. This eliminates +// timing-induced flakiness and makes the tick count deterministic. See +// [WithSynctest] for the constraints (no real I/O in the condition, +// requires [*testing.T]). Note: [Never] does not accept the context/error +// form of condition, so [WithSynctestContext] does not apply here. +// // # Examples // // success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and stops execution. -func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { +func Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Never(t, condition, timeout, tick, msgAndArgs...) { + if assertions.Never[C](t, condition, timeout, tick, msgAndArgs...) { return } diff --git a/require/require_format.go b/require/require_format.go index 08773917a..30cace309 100644 --- a/require/require_format.go +++ b/require/require_format.go @@ -1064,11 +1064,11 @@ func NegativeTf[SignedNumber SignedNumeric](t T, e SignedNumber, msg string, arg // Neverf is the same as [Never], but it accepts a format string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. -func Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) { +func Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Never(t, condition, timeout, tick, forwardArgs(msg, args)) { + if assertions.Never[C](t, condition, timeout, tick, forwardArgs(msg, args)) { return } diff --git a/require/require_forward.go b/require/require_forward.go index 3e660cdec..be0b2a16c 100644 --- a/require/require_forward.go +++ b/require/require_forward.go @@ -1394,34 +1394,6 @@ func (a *Assertions) Negativef(e any, msg string, args ...any) { a.T.FailNow() } -// Never is the same as [Never], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Never(condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.Never(a.T, condition, timeout, tick, msgAndArgs...) { - return - } - - a.T.FailNow() -} - -// Neverf is the same as [Assertions.Never], but it accepts a format string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Neverf(condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.Never(a.T, condition, timeout, tick, forwardArgs(msg, args)) { - return - } - - a.T.FailNow() -} - // Nil is the same as [Nil], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and stops execution. diff --git a/require/require_forward_test.go b/require/require_forward_test.go index b535a1157..f1714d94a 100644 --- a/require/require_forward_test.go +++ b/require/require_forward_test.go @@ -1223,31 +1223,6 @@ func TestAssertionsNegative(t *testing.T) { }) } -func TestAssertionsNever(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Never(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) - // require functions don't return a value - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Never(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond) - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.Never should call FailNow()") - } - }) -} - func TestAssertionsNil(t *testing.T) { t.Parallel() @@ -3215,31 +3190,6 @@ func TestAssertionsNegativef(t *testing.T) { }) } -func TestAssertionsNeverf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Neverf(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") - // require functions don't return a value - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Neverf(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond, "test message") - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.Neverf should call FailNow()") - } - }) -} - func TestAssertionsNilf(t *testing.T) { t.Parallel() diff --git a/require/require_types.go b/require/require_types.go index b8dc1cf44..2d429c38f 100644 --- a/require/require_types.go +++ b/require/require_types.go @@ -35,6 +35,9 @@ type ( // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. // // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + // + // The [WithSynctestCollect] and [WithSynctestCollectContext] wrappers opt a + // call into fake-time polling. See [WithSynctest] for details. CollectibleConditioner = assertions.CollectibleConditioner // ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful @@ -44,6 +47,9 @@ type ( // Conditioner is a function used in asynchronous condition assertions. // // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + // + // The [WithSynctest] and [WithSynctestContext] wrappers opt a call into + // fake-time polling via [testing/synctest]. See [WithSynctest] for details. Conditioner = assertions.Conditioner // ErrorAssertionFunc is a common function prototype when validating an error value. Can be useful @@ -61,6 +67,14 @@ type ( // NOTE: unfortunately complex64 and complex128 are not supported. Measurable = assertions.Measurable + // NeverConditioner is a function used by [Never]. + // + // Unlike [Conditioner], [Never] does not accept the context-returning-error + // form to avoid the double-negation confusion ("never returns no error"). + // + // The [WithSynctest] wrapper opts a call into fake-time polling. + NeverConditioner = assertions.NeverConditioner + // Ordered is a standard ordered type (i.e. types that support "<": [cmp.Ordered]) plus []byte and [time.Time]. // // This is used by [GreaterT], [GreaterOrEqualT], [LessT], [LessOrEqualT], [IsIncreasingT], [IsDecreasingT]. @@ -104,6 +118,49 @@ type ( // ValueAssertionFunc is a common function prototype when validating a single value. Can be useful // for table driven tests. ValueAssertionFunc func(T, any, ...any) + + // WithSynctest wraps a [func() bool] condition to run [Eventually] / + // [Never] / [Consistently] polling inside a [testing/synctest] bubble, + // so `time.Ticker`, `time.After`, and `context.WithTimeout` use a fake + // clock. Activation requires the caller to pass a real `*testing.T`; + // with mocks or other [T] implementations, the wrapper falls back to + // real-time polling. + // + // # When to use + // + // Use when the condition is pure compute, relies on `time.Sleep`, or + // coordinates via channels created inside the condition. Fake time + // eliminates timing-induced flakiness and enables deterministic tick + // counts. + // + // # When not to use + // + // Do NOT use when the condition performs real I/O (network, filesystem, + // syscalls): those block goroutines non-durably, so the fake clock + // stalls and the timeout may not fire. Also do NOT use inside a test + // that is already running in a [synctest.Test] bubble — nested bubbles + // are forbidden and will panic. + // + // # Shared state + // + // The condition may read and write variables captured from the enclosing + // scope; condition execution is serialized by design (see [Eventually]'s + // Concurrency section). Avoid sharing channels or mutexes with goroutines + // outside the bubble, as this will stall the fake clock. + WithSynctest = assertions.WithSynctest + + // WithSynctestCollect is the [func(*CollectT)] counterpart of + // [WithSynctest] for use with [EventuallyWith]. See [WithSynctest] for details. + WithSynctestCollect = assertions.WithSynctestCollect + + // WithSynctestCollectContext is the [func(context.Context, *CollectT)] + // counterpart of [WithSynctest] for use with [EventuallyWith]. See + // [WithSynctest] for details. + WithSynctestCollectContext = assertions.WithSynctestCollectContext + + // WithSynctestContext is the [func(context.Context) error] counterpart + // of [WithSynctest]. See [WithSynctest] for details. + WithSynctestContext = assertions.WithSynctestContext ) // Type declarations for backward compatibility.