Skip to content

Commit b635c75

Browse files
authored
feat(condition): EventuallyWith now supports require in condition (#90)
* fix(condition): in async testing, allow condition goroutine to exit * allows condition to Goexit without exiting the whole assertion * feat(condition): CollectT now distinguishes between FailNow() and Cancel() * FailNow() exits the condition, Cancel() interrupts the whole assertion * regenerated code with examples and doc strings * fixes #90 Follow-up PRs: 1. Update documentation and code examples 2. Enhance / better report to user in migration tool Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 22c49f1 commit b635c75

File tree

9 files changed

+234
-28
lines changed

9 files changed

+234
-28
lines changed

assert/assert_assertions.go

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assert/assert_assertions_test.go

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assert/assert_format_test.go

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/doc-site/api/condition.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -798,13 +798,23 @@ The supplied [CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/asse
798798
If the condition is not met before the timeout, the collected errors from the
799799
last tick are copied to t.
800800

801-
Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) cancels the condition immediately and causes the assertion to fail.
801+
Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) (directly, or transitively through [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions)
802+
fails the current tick only: the poller will retry on the next tick. This means
803+
[require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require)-style assertions inside [EventuallyWith](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#EventuallyWith) behave naturally — they abort
804+
the current evaluation and let the polling loop converge.
805+
806+
To abort the whole assertion immediately (e.g. when the condition can no longer
807+
be expected to succeed), call [CollectT.Cancel](https://pkg.go.dev/CollectT#Cancel).
802808

803809
#### Concurrency
804810

805811
The condition function is never executed in parallel: only one goroutine executes it.
806812
It may write to variables outside its scope without triggering race conditions.
807813

814+
The condition is wrapped in its own goroutine, so a call to [runtime.Goexit](https://pkg.go.dev/runtime#Goexit)
815+
(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
816+
current tick.
817+
808818
{{% expand title="Examples" %}}
809819
{{< tabs >}}
810820
{{% tab title="Usage" %}}
@@ -824,6 +834,7 @@ It may write to variables outside its scope without triggering race conditions.
824834
)
825835
success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond
826836
failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond
837+
failure: func(c *CollectT) { c.Cancel() }, 100*time.Millisecond, 20*time.Millisecond
827838
```
828839
{{< /tab >}}
829840
{{% tab title="Testable Examples (assert)" %}}
@@ -924,7 +935,7 @@ func main() {
924935
|--|--|
925936
| [`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 |
926937

927-
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L253)
938+
**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L264)
928939
{{% /tab %}}
929940
{{< /tabs >}}
930941

internal/assertions/condition.go

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,13 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t
222222
// If the condition is not met before the timeout, the collected errors from the
223223
// last tick are copied to t.
224224
//
225-
// Calling [CollectT.FailNow] cancels the condition immediately and causes the assertion to fail.
225+
// Calling [CollectT.FailNow] (directly, or transitively through [require] assertions)
226+
// fails the current tick only: the poller will retry on the next tick. This means
227+
// [require]-style assertions inside [EventuallyWith] behave naturally — they abort
228+
// the current evaluation and let the polling loop converge.
229+
//
230+
// To abort the whole assertion immediately (e.g. when the condition can no longer
231+
// be expected to succeed), call [CollectT.Cancel].
226232
//
227233
// # Usage
228234
//
@@ -246,10 +252,15 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t
246252
// The condition function is never executed in parallel: only one goroutine executes it.
247253
// It may write to variables outside its scope without triggering race conditions.
248254
//
255+
// The condition is wrapped in its own goroutine, so a call to [runtime.Goexit]
256+
// (e.g. via [require] assertions or [CollectT.FailNow]) cleanly aborts only the
257+
// current tick.
258+
//
249259
// # Examples
250260
//
251261
// success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond
252262
// failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond
263+
// failure: func(c *CollectT) { c.Cancel() }, 100*time.Millisecond, 20*time.Millisecond
253264
func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool {
254265
// Domain: condition
255266
if h, ok := t.(H); ok {
@@ -558,9 +569,19 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF
558569
case <-ctx.Done():
559570
return // timeout = success
560571
case fn := <-p.conditionChan:
561-
if err := fn(ctx); err != nil {
562-
close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently
572+
var conditionWg sync.WaitGroup
573+
conditionWg.Go(func() { // guards against the condition issue an early GoExit
574+
575+
if err := fn(ctx); err != nil {
576+
close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently
577+
}
578+
})
579+
conditionWg.Wait()
580+
581+
select {
582+
case <-p.doneChan: // done: early exit
563583
return
584+
default:
564585
}
565586
}
566587
}
@@ -577,9 +598,19 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF
577598
failFunc(ctx.Err().Error())
578599
return
579600
case fn := <-p.conditionChan:
580-
if err := fn(ctx); err == nil {
581-
close(p.doneChan) // (condition true <=> err == nil) = success for Eventually
601+
var conditionWg sync.WaitGroup
602+
conditionWg.Go(func() { // guards against the condition issue an early GoExit
603+
604+
if err := fn(ctx); err == nil {
605+
close(p.doneChan) // (condition true <=> err == nil) = success for Eventually
606+
}
607+
})
608+
conditionWg.Wait()
609+
610+
select {
611+
case <-p.doneChan: // done: early exit
582612
return
613+
default:
583614
}
584615
}
585616
}
@@ -660,6 +691,15 @@ func (p *conditionPoller) cancellableContext(parentCtx context.Context, timeout
660691
return ctx, cancel
661692
}
662693

694+
// Sentinel errors recorded by [CollectT.FailNow] and [CollectT.Cancel].
695+
// Kept package-private: callers should rely on observable behavior, not on
696+
// the marker shape. They are distinguishable so future tooling can tell apart
697+
// "tick aborted by require" from "user explicitly cancelled the assertion".
698+
var (
699+
errFailNow = errors.New("collect: failed now (tick aborted)")
700+
errCancelled = errors.New("collect: cancelled (assertion aborted)")
701+
)
702+
663703
// CollectT implements the [T] interface and collects all errors.
664704
//
665705
// [CollectT] is specifically intended to be used with [EventuallyWith] and
@@ -668,16 +708,20 @@ type CollectT struct {
668708
// Domain: condition
669709
//
670710
// Maintainer:
671-
// 1. FailNow() no longer just exits the go routine, but cancels the context of the caller instead before exiting.
672-
// 2. We no longer establish the distinction between c.error nil or empty. Non-empty is an error, full stop.
673-
// 2. Deprecated methods have been removed.
711+
// 1. FailNow() exits the current tick goroutine via runtime.Goexit (matching
712+
// stretchr/testify semantics): require-style assertions abort the current
713+
// evaluation and the poller retries on the next tick. It does NOT cancel
714+
// the EventuallyWith context.
715+
// 2. Cancel() is the explicit escape hatch: it cancels the EventuallyWith
716+
// context before exiting via runtime.Goexit, aborting the whole assertion.
717+
// 3. We no longer establish the distinction between c.errors nil or empty.
718+
// Non-empty is an error, full stop.
719+
// 4. Deprecated methods have been removed.
674720

675721
// A slice of errors. Non-empty slice denotes a failure.
676-
// NOTE: When c.FailNow() is called, it cancels the context and exits the goroutine.
677-
// The "failed now" error is appended but may be lost if the goroutine exits before collection.
678722
errors []error
679723

680-
// cancelContext cancels the parent context on FailNow()
724+
// cancelContext cancels the parent EventuallyWith context on Cancel().
681725
cancelContext func()
682726
}
683727

@@ -689,13 +733,33 @@ func (c *CollectT) Errorf(format string, args ...any) {
689733
c.errors = append(c.errors, fmt.Errorf(format, args...))
690734
}
691735

692-
// FailNow records a failure and cancels the parent [EventuallyWith] context,
693-
// before exiting the current go routine with [runtime.Goexit].
736+
// FailNow records a failure for the current tick and exits the condition
737+
// goroutine via [runtime.Goexit].
694738
//
695-
// This causes the assertion to fail immediately without waiting for a timeout.
739+
// It does NOT cancel the [EventuallyWith] context: the poller will retry on
740+
// the next tick. If a later tick succeeds, the assertion succeeds. If no tick
741+
// ever succeeds before the timeout, the errors collected during the LAST tick
742+
// (the one which most recently called FailNow) are reported on the parent t.
743+
//
744+
// To abort the whole assertion immediately, use [CollectT.Cancel].
696745
func (c *CollectT) FailNow() {
746+
c.errors = append(c.errors, errFailNow)
747+
runtime.Goexit()
748+
}
749+
750+
// Cancel records a failure, cancels the [EventuallyWith] context, then exits
751+
// the condition goroutine via [runtime.Goexit].
752+
//
753+
// This aborts the whole assertion immediately, without waiting for the timeout.
754+
// The errors collected during the cancelled tick are reported on the parent t.
755+
//
756+
// Use this when the condition can no longer be expected to succeed (e.g. an
757+
// upstream resource has been observed in an unrecoverable state). For ordinary
758+
// per-tick failures (e.g. "value not yet ready"), use [CollectT.FailNow]
759+
// directly or transitively through [require] assertions.
760+
func (c *CollectT) Cancel() {
761+
c.errors = append(c.errors, errCancelled)
697762
c.cancelContext()
698-
c.errors = append(c.errors, errors.New("failed now")) // so c.failed() is true (currently lost as not owned by another go routine)
699763
runtime.Goexit()
700764
}
701765

internal/assertions/condition_test.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ func TestConditionEventuallyNoLeak(t *testing.T) {
294294
})
295295
}
296296

297+
//nolint:gocognit,gocyclo,cyclop // subtests are actually not complex
297298
func TestConditionEventuallyWith(t *testing.T) {
298299
t.Parallel()
299300

@@ -406,28 +407,88 @@ func TestConditionEventuallyWith(t *testing.T) {
406407
}
407408
})
408409

409-
t.Run("should fail with a call to collect.FailNow", func(t *testing.T) {
410+
t.Run("collect.FailNow only fails the current tick (poller retries)", func(t *testing.T) {
410411
t.Parallel()
411412

412413
mock := new(errorsCapturingT)
413-
counter := 0
414+
var counter int
415+
var mu sync.Mutex
414416

415-
// The call to FailNow cancels the execution context of EventuallyWith.
416-
// so we don't have to wait for the timeout.
417+
// FailNow on every tick: the poller must keep retrying until the timeout.
417418
condition := func(collect *CollectT) {
419+
mu.Lock()
418420
counter++
421+
mu.Unlock()
419422
collect.FailNow()
420423
}
421424

425+
if EventuallyWith(mock, condition, testTimeout, testTick) {
426+
t.Error("expected EventuallyWith to return false")
427+
}
428+
mu.Lock()
429+
got := counter
430+
mu.Unlock()
431+
if got < 2 {
432+
t.Errorf("expected the condition to be retried multiple times, got %d call(s)", got)
433+
}
434+
})
435+
436+
t.Run("collect.FailNow allows convergence on a later tick", func(t *testing.T) {
437+
t.Parallel()
438+
439+
mock := new(errorsCapturingT)
440+
var counter int
441+
var mu sync.Mutex
442+
443+
// First few ticks fail via FailNow, then converge.
444+
condition := func(collect *CollectT) {
445+
mu.Lock()
446+
counter++
447+
n := counter
448+
mu.Unlock()
449+
if n < 3 {
450+
collect.FailNow()
451+
}
452+
}
453+
454+
if !EventuallyWith(mock, condition, testTimeout, testTick) {
455+
t.Error("expected EventuallyWith to eventually return true")
456+
}
457+
if len(mock.errors) != 0 {
458+
t.Errorf("expected no errors reported on parent t after success, got %d: %v", len(mock.errors), mock.errors)
459+
}
460+
})
461+
462+
t.Run("collect.Cancel aborts the whole assertion immediately", func(t *testing.T) {
463+
t.Parallel()
464+
465+
mock := new(errorsCapturingT)
466+
var counter int
467+
var mu sync.Mutex
468+
469+
// Cancel must short-circuit: a 30-minute timeout must NOT be waited on.
470+
condition := func(collect *CollectT) {
471+
mu.Lock()
472+
counter++
473+
mu.Unlock()
474+
collect.Cancel()
475+
}
476+
477+
start := time.Now()
422478
if EventuallyWith(mock, condition, 30*time.Minute, testTick) {
423479
t.Error("expected EventuallyWith to return false")
424480
}
425-
const expectedErrors = 2
426-
if len(mock.errors) != expectedErrors {
427-
t.Errorf("expected %d errors (0 accumulated + 2 from EventuallyWith), got %d", expectedErrors, len(mock.errors))
481+
if elapsed := time.Since(start); elapsed > 5*time.Second {
482+
t.Errorf("expected Cancel to short-circuit, but EventuallyWith took %s", elapsed)
483+
}
484+
mu.Lock()
485+
got := counter
486+
mu.Unlock()
487+
if got != 1 {
488+
t.Errorf("expected the condition function to have been called only once, but got: %d", got)
428489
}
429-
if counter != 1 {
430-
t.Errorf("expected the condition function to have been called only once, but got: %d", counter)
490+
if len(mock.errors) == 0 {
491+
t.Error("expected at least one error reported on parent t after Cancel")
431492
}
432493
})
433494
}

0 commit comments

Comments
 (0)