Files
tasks/common/testing/await/require_true_test.go
Stephan BehnkeandGitHub 4db423e1bb add await.Require and await.RequireTrue (#9490)
## What changed?

Introduces `common/testing/await`, a polling-based test helper that
replaces testify's `Eventually` / `EventuallyWithT`.

## Why?

From the [package
doc](https://github.com/temporalio/temporal/blob/stephanos/all-require/common/testing/await/await.go):

```
Improvements over testify's eventually functions:

   - Misuse detection: accidentally using the real *testing.T (e.g. s.T() or
     suite assertion methods) instead of the callback's collect T is a
     common mistake. This package detects it and fails with a clear message.

   - Safer bool predicates: unlike testify's Eventually, [RequireTrue] only
     accepts func() bool, so returning false is the sole retry signal. If the
     predicate accidentally marks the real test failed, it reports that
     immediately instead of polling until timeout.

   - Timeout-aware callbacks: callbacks receive a context derived from the
     parent context and canceled when the await timeout or test deadline is
     reached, so RPCs and blocking waits can exit instead of continuing after
     the retry window has expired.

   - Panic propagation: if the condition panics (e.g. nil dereference), the
     panic is propagated immediately rather than being silently swallowed
     or retried until timeout.
     See https:github.com/stretchr/testify/issues/1810

   - Bounded goroutine lifetime: each attempt completes before the next
     starts, avoiding the overlapping-attempt data races and "panic: Fail
     in goroutine after Test has completed" crashes seen with testify's
     Eventually.
     See https:github.com/stretchr/testify/issues/1611

   - Deadlock detection: a condition that ignores t.Context() is abandoned
     after a grace period, producing a clear "does it honor t.Context()?"
     failure instead of hanging until go test -timeout.

   - Condition always runs: testify's Eventually can fail without ever
     running the condition due to a timer/ticker race with short timeouts.
     This package runs the condition immediately on the first iteration.
     See https://github.com/stretchr/testify/issues/1652
```

The upstream fix
([testify#1657](https://github.com/stretchr/testify/pull/1657)) aims to
address several of these but has been open since Oct 2024 without
merging.

## How did you test it?
- [ ] built
- [ ] run locally and tested manually
- [x] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks

The biggest downside is that it adds more code that we own. But apart
from any unknown bugs, this package should rarely/never change.
2026-05-15 21:55:32 +00:00

134 lines
3.4 KiB
Go

package await_test
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.temporal.io/server/common/testing/await"
)
// RequireTrue is a thin bool-predicate adapter over the same polling runner
// covered by require_ctx_test.go, so these tests focus on adapter behavior.
func TestRequireTrue_ImmediateSuccess(t *testing.T) {
t.Parallel()
attempts := 0
await.RequireTrue(t, func() bool {
attempts++
return true
}, time.Second, 100*time.Millisecond)
require.Equal(t, 1, attempts, "condition should be called exactly once")
}
func TestRequireTrue_RetriesFalseUntilTrue(t *testing.T) {
t.Parallel()
var attempts atomic.Int32
await.RequireTrue(t, func() bool {
return attempts.Add(1) >= 3
}, time.Second, 100*time.Millisecond)
require.Equal(t, int32(3), attempts.Load())
}
func TestRequireTrue_FailureScenarios(t *testing.T) {
t.Parallel()
t.Run("reports timeout", func(t *testing.T) {
t.Parallel()
tb := newRecordingTB()
tb.run(func() {
await.RequireTrue(tb, func() bool {
return false
}, time.Second, 100*time.Millisecond)
})
require.True(t, tb.Failed())
require.Contains(t, tb.fatals(), "not satisfied after")
})
t.Run("RequireTruef includes message on timeout", func(t *testing.T) {
t.Parallel()
tb := newRecordingTB()
tb.run(func() {
await.RequireTruef(tb, func() bool {
return false
}, time.Second, 100*time.Millisecond, "workflow %s not ready", "wf-123")
})
require.True(t, tb.Failed())
require.Contains(t, tb.fatals(), "workflow wf-123 not ready")
})
t.Run("reports real TB misuse", func(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
misuse func(*recordingTB)
}{
{"Fatal stops real TB", func(tb *recordingTB) { tb.Fatal("wrong t used") }},
{"Errorf marks real TB failed", func(tb *recordingTB) { tb.Errorf("assert-style misuse") }},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tb := newRecordingTB()
tb.run(func() {
await.RequireTrue(tb, func() bool {
tc.misuse(tb)
return true
}, time.Second, 100*time.Millisecond)
})
require.True(t, tb.Failed())
require.Contains(t, tb.fatals(), "do not use test assertions")
})
}
})
t.Run("does not poll after prior failure", func(t *testing.T) {
t.Parallel()
conditionCalled := false
tb := newRecordingTB()
tb.run(func() {
tb.Errorf("previous failure")
await.RequireTrue(tb, func() bool {
conditionCalled = true
return true
}, time.Second, 100*time.Millisecond)
})
require.True(t, tb.Failed())
require.Empty(t, tb.fatals())
require.False(t, conditionCalled, "condition should not run when test already failed")
})
}
func TestRequireTrue_DeadlockDetected(t *testing.T) {
// not using T.Parallel() so it can use t.Setenv to override the deadlock timeouts
t.Setenv("TEMPORAL_AWAIT_HARD_DEADLOCK_TIMEOUT", "100ms")
const awaitTimeout = 10 * time.Second
tb := newRecordingTB()
start := time.Now()
tb.run(func() {
await.RequireTrue(tb, func() bool {
select {} // never returns; predicate has no way to honor cancellation
}, awaitTimeout, 50*time.Millisecond)
})
elapsed := time.Since(start)
require.True(t, tb.Failed())
require.Contains(t, tb.fatals(), "still running")
require.Contains(t, tb.fatals(), "past deadline")
require.Less(t, elapsed, awaitTimeout,
"should fail at hard deadlock, not wait the full await timeout (elapsed=%v)", elapsed)
}