Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions assert/assert_assertions.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 36 additions & 6 deletions docs/doc-site/api/condition.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ This is consistent with [Eventually](https://pkg.go.dev/github.com/go-openapi/te
It will be executed with the context of the assertion, which inherits the [testing.T.Context](https://pkg.go.dev/testing#T.Context) and
is cancelled on timeout.

#### Panic recovery

A panicking condition is treated as an error, causing [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) to fail immediately.
See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details.

#### Concurrency

See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually).
Expand All @@ -192,7 +197,7 @@ See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Even
{{% tab title="Usage" %}}
```go
assertions.Consistently(t, func() bool { return true }, time.Second, 10*time.Millisecond)
See also [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details about using context and concurrency.
See also [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details about using context, concurrency, and panic recovery.
success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond
failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond
```
Expand Down Expand Up @@ -444,7 +449,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#L204)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Consistently](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L225)
{{% /tab %}}
{{< /tabs >}}

Expand Down Expand Up @@ -501,6 +506,17 @@ A blocking condition will cause [Eventually](https://pkg.go.dev/github.com/go-op

Notice that time ticks may be skipped if the condition takes longer than the tick interval.

#### Panic recovery

If the condition panics, the panic is recovered and treated as a failed tick
(equivalent to returning false or a non-nil error). For [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually), this means
the poller retries on the next tick — if a later tick succeeds, the assertion
succeeds. For [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) and [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently), a panic is treated as the condition
erroring, which causes immediate failure.

The recovered panic is wrapped as an error with the sentinel [errConditionPanicked](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#errConditionPanicked),
detectable with [errors.Is](https://pkg.go.dev/errors#Is).

#### Attention point

Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce
Expand Down Expand Up @@ -781,7 +797,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#L108)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Eventually](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L119)
{{% /tab %}}
{{< /tabs >}}

Expand Down Expand Up @@ -815,6 +831,15 @@ The condition is wrapped in its own goroutine, so a call to [runtime.Goexit](htt
(e.g. via [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions or [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow)) cleanly aborts only the
current tick.

#### Panic recovery

If the condition panics, the panic is recovered and recorded as an error in the
[CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#CollectT) for that tick. The poller treats it as a failed tick and retries on the
next one. If the assertion times out, the panic error is included in the collected
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.

{{% expand title="Examples" %}}
{{< tabs >}}
{{% tab title="Usage" %}}
Expand Down Expand Up @@ -935,7 +960,7 @@ 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#L264)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L294)
{{% /tab %}}
{{< /tabs >}}

Expand All @@ -956,6 +981,11 @@ The simplest form of condition is:

Use [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) instead if you want to use a condition returning an error.

#### Panic recovery

A panicking condition is treated as an error, causing [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) to fail immediately.
See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details.

#### Concurrency

See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually).
Expand All @@ -969,7 +999,7 @@ See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Even
{{% tab title="Usage" %}}
```go
assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond)
See also [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details about using context and concurrency.
See also [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details about using context, concurrency, and panic recovery.
success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond
failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond
```
Expand Down Expand Up @@ -1157,7 +1187,7 @@ func main() {
|--|--|
| [`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 |

**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L151)
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L167)
{{% /tab %}}
{{< /tabs >}}

Expand Down
76 changes: 65 additions & 11 deletions internal/assertions/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ func Condition(t T, comp func() bool, msgAndArgs ...any) bool {
//
// Notice that time ticks may be skipped if the condition takes longer than the tick interval.
//
// # Panic recovery
//
// If the condition panics, the panic is recovered and treated as a failed tick
// (equivalent to returning false or a non-nil error). For [Eventually], this means
// the poller retries on the next tick — if a later tick succeeds, the assertion
// succeeds. For [Never] and [Consistently], a panic is treated as the condition
// erroring, which causes immediate failure.
//
// The recovered panic is wrapped as an error with the sentinel [errConditionPanicked],
// detectable with [errors.Is].
//
// # Attention point
//
// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce
Expand Down Expand Up @@ -126,7 +137,7 @@ func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim
//
// assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond)
//
// See also [Eventually] for details about using context and concurrency.
// See also [Eventually] for details about using context, concurrency, and panic recovery.
//
// # Alternative condition signature
//
Expand All @@ -136,6 +147,11 @@ func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick tim
//
// Use [Consistently] instead if you want to use a condition returning an error.
//
// # Panic recovery
//
// A panicking condition is treated as an error, causing [Never] to fail immediately.
// See [Eventually] for details.
//
// # Concurrency
//
// See [Eventually].
Expand Down Expand Up @@ -168,7 +184,7 @@ func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration
//
// assertions.Consistently(t, func() bool { return true }, time.Second, 10*time.Millisecond)
//
// See also [Eventually] for details about using context and concurrency.
// See also [Eventually] for details about using context, concurrency, and panic recovery.
//
// # Alternative condition signature
//
Expand All @@ -189,6 +205,11 @@ func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration
// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and
// is cancelled on timeout.
//
// # Panic recovery
//
// A panicking condition is treated as an error, causing [Consistently] to fail immediately.
// See [Eventually] for details.
//
// # Concurrency
//
// See [Eventually].
Expand Down Expand Up @@ -256,6 +277,15 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t
// (e.g. via [require] assertions or [CollectT.FailNow]) cleanly aborts only the
// current tick.
//
// # Panic recovery
//
// If the condition panics, the panic is recovered and recorded as an error in the
// [CollectT] for that tick. The poller treats it as a failed tick and retries on the
// next one. If the assertion times out, the panic error is included in the collected
// errors reported on the parent t.
//
// See [Eventually] for the general panic recovery semantics.
//
// # Examples
//
// success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond
Expand Down Expand Up @@ -318,13 +348,21 @@ func eventuallyWithT[C CollectibleConditioner](t T, collectCondition C, timeout
var cancelFunc func() // will be set by pollCondition via onSetup
fn := makeCollectibleCondition(collectCondition)

condition := func(ctx context.Context) error {
condition := func(ctx context.Context) (err error) {
collector := new(CollectT).withCancelFunc(cancelFunc)

defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: %v", errConditionPanicked, r)
collector.errors = append(collector.errors, err)
}
if collector.failed() {
lastCollectedErrors = collector.collected()
err = collector.last()
}
}()

fn(ctx, collector)
if collector.failed() {
lastCollectedErrors = collector.collected()
return collector.last()
}

return nil
}
Expand Down Expand Up @@ -408,6 +446,18 @@ func makeCollectibleCondition[C CollectibleConditioner](condition C) func(contex
}
}

func recoverCondition(fn func(context.Context) error) func(context.Context) error {
return func(ctx context.Context) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: %v", errConditionPanicked, r)
}
}()

return fn(ctx)
}
}

type conditionPoller struct {
pollOptions

Expand Down Expand Up @@ -462,6 +512,8 @@ func (p *conditionPoller) pollCondition(t T, condition func(context.Context) err
p.onSetup(cancel)
}

condition = recoverCondition(condition)

p.ticker = time.NewTicker(tick)
defer p.ticker.Stop()

Expand Down Expand Up @@ -691,13 +743,15 @@ func (p *conditionPoller) cancellableContext(parentCtx context.Context, timeout
return ctx, cancel
}

// Sentinel errors recorded by [CollectT.FailNow] and [CollectT.Cancel].
// Sentinel errors recorded by async condition assertions.
// Kept package-private: callers should rely on observable behavior, not on
// the marker shape. They are distinguishable so future tooling can tell apart
// "tick aborted by require" from "user explicitly cancelled the assertion".
// "tick aborted by require", "user explicitly cancelled the assertion",
// and "condition panicked".
var (
errFailNow = errors.New("collect: failed now (tick aborted)")
errCancelled = errors.New("collect: cancelled (assertion aborted)")
errFailNow = errors.New("collect: failed now (tick aborted)")
errCancelled = errors.New("collect: cancelled (assertion aborted)")
errConditionPanicked = errors.New("condition panicked")
)

// CollectT implements the [T] interface and collects all errors.
Expand Down
Loading
Loading