mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## 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.
180 lines
4.2 KiB
Go
180 lines
4.2 KiB
Go
package testcontext
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.temporal.io/server/common/debug"
|
|
)
|
|
|
|
const defaultTimeout = 90 * time.Second
|
|
|
|
type contextStore struct {
|
|
sync.Mutex
|
|
byTest map[testing.TB]*contextState
|
|
}
|
|
|
|
// testContexts is process-global so repeated helpers in the same test share
|
|
// one context and one cleanup.
|
|
var testContexts = contextStore{
|
|
byTest: make(map[testing.TB]*contextState),
|
|
}
|
|
|
|
type config struct {
|
|
timeout time.Duration
|
|
timeoutSet bool
|
|
decorators []contextDecorator
|
|
}
|
|
|
|
type contextDecorator struct {
|
|
key any
|
|
decorate func(context.Context) context.Context
|
|
}
|
|
|
|
// New returns the test-scoped context for tb. The context is canceled when the
|
|
// test ends or when the configured test timeout expires.
|
|
//
|
|
// The first call creates the per-test context and fixes its timeout. Later calls
|
|
// may add decorators, but an explicit different timeout fails instead of being
|
|
// silently ignored.
|
|
func New(tb testing.TB, opts ...Option) context.Context {
|
|
tb.Helper()
|
|
|
|
cfg := config{timeout: effectiveTimeout(0)}
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
|
|
st := getContextState(tb, cfg.timeout)
|
|
st.configure(tb, cfg)
|
|
return st.context()
|
|
}
|
|
|
|
// Option configures the test-scoped context returned by [New].
|
|
type Option func(*config)
|
|
|
|
// WithTimeout sets a custom timeout for the test-scoped context.
|
|
func WithTimeout(timeout time.Duration) Option {
|
|
return func(cfg *config) {
|
|
if timeout <= 0 {
|
|
return
|
|
}
|
|
cfg.timeout = effectiveTimeout(timeout)
|
|
cfg.timeoutSet = true
|
|
}
|
|
}
|
|
|
|
// WithContextDecorator applies decorator to the test-scoped context once for key.
|
|
// Reusing the same key is a no-op.
|
|
func WithContextDecorator[K comparable](key K, decorator func(context.Context) context.Context) Option {
|
|
return func(cfg *config) {
|
|
cfg.decorators = append(cfg.decorators, contextDecorator{
|
|
key: key,
|
|
decorate: decorator,
|
|
})
|
|
}
|
|
}
|
|
|
|
type contextState struct {
|
|
mu sync.Mutex
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
timeout time.Duration
|
|
decorators map[any]struct{}
|
|
}
|
|
|
|
func getContextState(tb testing.TB, timeout time.Duration) *contextState {
|
|
tb.Helper()
|
|
|
|
testContexts.Lock()
|
|
defer testContexts.Unlock()
|
|
|
|
if st, ok := testContexts.byTest[tb]; ok {
|
|
return st
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(tb.Context(), timeout)
|
|
st := &contextState{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
timeout: timeout,
|
|
decorators: make(map[any]struct{}),
|
|
}
|
|
testContexts.byTest[tb] = st
|
|
|
|
tb.Cleanup(func() {
|
|
st.cancel()
|
|
testContexts.Lock()
|
|
delete(testContexts.byTest, tb)
|
|
testContexts.Unlock()
|
|
if st.err() == context.DeadlineExceeded {
|
|
tb.Errorf("Test exceeded timeout of %v", st.timeout)
|
|
}
|
|
})
|
|
return st
|
|
}
|
|
|
|
func (s *contextState) configure(tb testing.TB, cfg config) {
|
|
tb.Helper()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if cfg.timeoutSet && cfg.timeout != s.timeout {
|
|
tb.Fatalf("testcontext: test context already exists with timeout %v; cannot change it to %v", s.timeout, cfg.timeout)
|
|
}
|
|
|
|
// Decorators may be registered by independent helpers, so apply each keyed
|
|
// decorator at most once while preserving call order.
|
|
for _, decorator := range cfg.decorators {
|
|
if decorator.key == nil {
|
|
tb.Fatal("testcontext: context decorator key must not be nil")
|
|
}
|
|
if decorator.decorate == nil {
|
|
tb.Fatal("testcontext: context decorator must not be nil")
|
|
}
|
|
if _, ok := s.decorators[decorator.key]; ok {
|
|
continue
|
|
}
|
|
s.ctx = decorator.decorate(s.ctx)
|
|
s.decorators[decorator.key] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func (s *contextState) context() context.Context {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ctx
|
|
}
|
|
|
|
func (s *contextState) err() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ctx.Err()
|
|
}
|
|
|
|
func effectiveTimeout(customTimeout time.Duration) (timeout time.Duration) {
|
|
defer func() {
|
|
// Build flag TEMPORAL_DEBUG applies a timeout multiplier to all test timeouts.
|
|
timeout *= debug.TimeoutMultiplier
|
|
}()
|
|
|
|
// 1. Custom timeout (via WithTimeout option).
|
|
if customTimeout > 0 {
|
|
return customTimeout
|
|
}
|
|
|
|
// 2. TEMPORAL_TEST_TIMEOUT environment variable.
|
|
if envTimeout := os.Getenv("TEMPORAL_TEST_TIMEOUT"); envTimeout != "" {
|
|
if dur, err := time.ParseDuration(envTimeout); err == nil && dur > 0 {
|
|
return dur
|
|
}
|
|
}
|
|
|
|
// 3. Default 90 seconds.
|
|
return defaultTimeout
|
|
}
|