diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 47b49e760..ff1859def 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -92,7 +92,7 @@ codegen/ 7. `r.Equal(...)` - fatal forward method 8. `r.Equalf(..., "msg")` - fatal forward format variant -With 76 assertion functions, this generates 608 functions automatically. +With 127 assertion functions, this generates 840 functions automatically. ### Dependency Isolation Strategy - **internal/spew**: Internalized copy of go-spew for pretty-printing values @@ -141,7 +141,7 @@ cd codegen && go run . -output-packages assert,require -include-doc # Preview documentation site locally cd hack/doc-site/hugo -./gendoc.sh +go run gendoc.go # Visit http://localhost:1313/testify/ # The Hugo site auto-reloads on changes to docs/doc-site/ @@ -278,7 +278,7 @@ The codegen also generates domain-organized documentation for a Hugo static site **Hugo static site setup:** Located in `hack/doc-site/hugo/`: - **hugo.yaml** - Main Hugo configuration -- **gendoc.sh** - Development server script +- **gendoc.go** - Development server script - **themes/hugo-relearn** - Documentation theme - Mounts generated content from `docs/doc-site/` @@ -289,7 +289,7 @@ go generate ./... # Then start the Hugo dev server cd hack/doc-site/hugo -./gendoc.sh +go run gendoc.go # Visit http://localhost:1313/testify/ ``` @@ -489,10 +489,10 @@ func TestParseTestExamples(t *testing.T) { - Drop-in replacement for stretchr/testify **The Math:** -- 76 assertion functions × 8 variants = 608 functions -- Old model: Manually maintain 608 functions across multiple packages -- New model: Write 76 functions once, generate the rest -- Result: 87% reduction in manual code maintenance +- 127 assertion functions × 4-8 variants = 840 functions +- Old model: Manually maintain 840 functions across multiple packages +- New model: Write 127 functions once, generate the rest +- Result: 85% reduction in manual code maintenance ### Technical Innovations @@ -555,7 +555,7 @@ hack/doc-site/hugo/ # Note: Temporary location ├── hugo.yaml # Main Hugo configuration ├── testify.yaml # Generated config with version info ├── testify.yaml.template # Template for testify.yaml -├── gendoc.sh # Development server launcher +├── gendoc.go # Development server launcher ├── README.md, TODO.md # Documentation and planning ├── themes/ │ └── hugo-relearn/ # Documentation theme diff --git a/.github/wordlist.txt b/.github/wordlist.txt index fff7970e6..11f521368 100644 --- a/.github/wordlist.txt +++ b/.github/wordlist.txt @@ -1,10 +1,13 @@ API APIs +BDD BSON CI CIDR CLI CLIs +CodeFactor +CodeQL CSV DCO DockerHub @@ -22,15 +25,20 @@ IDs IP IPs ISBN +ISC JSON +JUnit Kubernetes +LOC Markdown NaN OAI OAuth OpenAPI +PHPUnit PR PRs +PyTest README SSN TCP @@ -64,17 +72,23 @@ bitmask bson bytesize cancelled +cgo ci cidr cli clis +codebase codecov codegen +colorizer +colorizers config configs csv customizable dependabot +dereference +dereferencing deserialize deserialized deserializer @@ -87,6 +101,7 @@ flattener fuzzying gc github +globals go-openapi godoc golang @@ -102,6 +117,7 @@ https hugo i.e. id +impactful implementor implementors initialism @@ -116,6 +132,7 @@ json jsonschema k8s kubernetes +lifecycle linter linters listA @@ -128,6 +145,7 @@ marshaling middleware middlewares mixin +monorepo multipart mutex oai @@ -136,8 +154,11 @@ oauth2 openapi param params +prepend +prepended rebase rebased +roadmap redeclare repo repos @@ -145,24 +166,30 @@ roundtrip roundtripper schema schemas +semver serialize serialized serializer sexualized +spdx ssn +stdlib struct structs submodule subpackage substring swagger +testify's tls toolchain ui ulid +uncategorized unmarshal unmarshaled unmarshaling +unexported untyped uri url @@ -177,4 +204,5 @@ validators waitFor workspace workspaces +xunit yaml diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9322b065e..817efcb87 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at ivan+abuse@flanders.co.nz. All +reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/README.md b/README.md index ceb34c3a4..e536d8ca8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Testify/v2 -[![Tests][test-badge]][test-url] [![Coverage][cov-badge]][cov-url] [![CI vuln scan][vuln-scan-badge]][vuln-scan-url] [![CodeQL][codeql-badge]][codeql-url] +[![Tests][test-badge]][test-url] [![Coverage][cov-badge]][cov-url] [![CI vulnerability scan][vuln-scan-badge]][vuln-scan-url] [![CodeQL][codeql-badge]][codeql-url] diff --git a/assert/assert_adhoc_example_8_test.go b/assert/assert_adhoc_example_8_test.go new file mode 100644 index 000000000..6207b9475 --- /dev/null +++ b/assert/assert_adhoc_example_8_test.go @@ -0,0 +1,115 @@ +// 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" +) + +// ExampleEventually_asyncReady demonstrates polling a condition that becomes true +// after a few attempts, simulating an asynchronous operation completing. +func ExampleEventually_asyncReady() { + t := new(testing.T) // normally provided by test + + // Simulate an async operation that completes after a short delay. + var ready atomic.Bool + go func() { + time.Sleep(30 * time.Millisecond) + ready.Store(true) + }() + + result := assert.Eventually(t, ready.Load, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually ready: %t", result) + + // Output: eventually ready: true +} + +// ExampleEventually_healthCheck demonstrates [Eventually] with a +// func(context.Context) error condition, polling until the operation +// succeeds (returns nil). +func ExampleEventually_healthCheck() { + t := new(testing.T) // normally provided by test + + // Simulate a service that becomes healthy after a few attempts. + 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, healthCheck, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually healthy: %t", result) + + // Output: eventually healthy: true +} + +// ExampleNever_noSpuriousEvents demonstrates asserting that a condition never becomes true +// during the observation period. +func ExampleNever_noSpuriousEvents() { + t := new(testing.T) // normally provided by test + + // A channel that should remain empty during the test. + events := make(chan struct{}, 1) + + result := assert.Never(t, func() bool { + select { + case <-events: + return true // event received = condition becomes true = Never fails + default: + return false + } + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("never received: %t", result) + + // Output: never received: true +} + +// ExampleConsistently_invariant demonstrates asserting that a condition remains true +// throughout the entire observation period. +func ExampleConsistently_invariant() { + t := new(testing.T) // normally provided by test + + // A counter that stays within bounds during the test. + var counter atomic.Int32 + counter.Store(5) + + result := assert.Consistently(t, func() bool { + return counter.Load() < 10 + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently under limit: %t", result) + + // Output: consistently under limit: true +} + +// ExampleConsistently_alwaysHealthy demonstrates [Consistently] with a +// func(context.Context) error condition, asserting that the operation +// always succeeds (returns nil) throughout the observation period. +func ExampleConsistently_alwaysHealthy() { + t := new(testing.T) // normally provided by test + + // Simulate a service that stays healthy. + healthCheck := func(_ context.Context) error { + return nil // always healthy + } + + result := assert.Consistently(t, healthCheck, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently healthy: %t", result) + + // Output: consistently healthy: true +} diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index 715ae64c3..781d97f3f 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -15,7 +15,7 @@ import ( "github.com/go-openapi/testify/v2/internal/assertions" ) -// Condition uses a [Comparison] to assert a complex condition. +// Condition uses a comparison function to assert a complex condition. // // # Usage // @@ -27,13 +27,66 @@ import ( // failure: func() bool { return false } // // Upon failure, the test [T] is marked as failed and continues execution. -func Condition(t T, comp Comparison, msgAndArgs ...any) bool { +func Condition(t T, comp func() bool, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } return assertions.Condition(t, comp, msgAndArgs...) } +// Consistently asserts that the given condition is always satisfied until timeout, +// periodically checking the target function at each tick. +// +// [Consistently] ("always") imposes a stronger constraint than [Eventually] ("at least once"): +// it checks at every tick that every occurrence of the condition is satisfied, whereas +// [Eventually] succeeds on the first occurrence of a successful condition. +// +// # Usage +// +// assertions.Consistently(t, func() bool { return true }, time.Second, 10*time.Millisecond) +// +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// The semantics of the assertion are "always returns true". +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails as soon as an error is returned before timeout expressing "always returns no error (nil)" +// +// This is consistent with [Eventually] expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// # Concurrency +// +// See [Eventually]. +// +// # Attention point +// +// See [Eventually]. +// +// # Examples +// +// success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond +// failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond +// +// Upon failure, the test [T] is marked as failed and continues execution. +func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.Consistently[C](t, condition, timeout, tick, msgAndArgs...) +} + // Contains asserts that the specified string, list(array, slice...) or map contains the // specified substring or element. // @@ -374,13 +427,13 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) bool { return assertions.ErrorIs(t, err, target, msgAndArgs...) } -// Eventually asserts that the given condition will be met in waitFor time, +// Eventually asserts that the given condition will be met before timeout, // periodically checking the target function on each tick. // -// [Eventually] waits until the condition returns true, for at most waitFor, +// [Eventually] waits until the condition returns true, at most until timeout, // or until the parent context of the test is cancelled. // -// If the condition takes longer than waitFor to complete, [Eventually] fails +// If the condition takes longer than the timeout to complete, [Eventually] fails // but waits for the current condition execution to finish before returning. // // For long-running conditions to be interrupted early, check [testing.T.Context] @@ -390,27 +443,68 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) bool { // // assertions.Eventually(t, func() bool { return true }, time.Second, 10*time.Millisecond) // +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails when an error has always been returned up to timeout (equivalent semantics to func() bool returns false), +// expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// The semantics of the three available async assertions read as follows. +// +// - [Eventually] (func() bool) : "eventually returns true" +// +// - [Never] (func() bool) : "never returns true" +// +// - [Consistently] (func() bool): "always returns true" +// +// - [Eventually] (func(ctx) error) : "eventually returns nil" +// +// - [Never] (func(ctx) error) : not supported, use [Consistently] instead (avoids confusion with double negation) +// +// - [Consistently] (func(ctx) error): "always returns nil" +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// The condition function is always executed serially by a single goroutine. It is always executed at least once. +// +// It may thus write to variables outside its scope without triggering race conditions. // // A blocking condition will cause [Eventually] to hang until it returns. // +// Notice that time ticks may be skipped if the condition takes longer than the tick interval. +// +// # Attention point +// +// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +// counter-intuitive results, such as ticks or timeouts not firing in time as expected. +// +// To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << +// timeout). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and continues execution. -func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.Eventually(t, condition, waitFor, tick, msgAndArgs...) + return assertions.Eventually[C](t, condition, timeout, tick, msgAndArgs...) } -// EventuallyWith asserts that the given condition will be met in waitFor time, +// EventuallyWith asserts that the given condition will be met before the timeout, // periodically checking the target function at each tick. // // In contrast to [Eventually], the condition function is supplied with a [CollectT] @@ -419,10 +513,10 @@ func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur // The condition is considered "met" if no errors are raised in a tick. // The supplied [CollectT] collects all errors from one tick. // -// If the condition is not met before waitFor, the collected errors from the +// If the condition is not met before the timeout, the collected errors from the // last tick are copied to t. // -// Calling [CollectT.FailNow] cancels the condition immediately and fails the assertion. +// Calling [CollectT.FailNow] cancels the condition immediately and causes the assertion to fail. // // # Usage // @@ -452,11 +546,11 @@ func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur // failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and continues execution. -func EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.EventuallyWith(t, condition, waitFor, tick, msgAndArgs...) + return assertions.EventuallyWith[C](t, condition, timeout, tick, msgAndArgs...) } // Exactly asserts that two objects are equal in value and type. @@ -1704,11 +1798,11 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an return assertions.NegativeT[SignedNumber](t, e, msgAndArgs...) } -// Never asserts that the given condition is never satisfied within waitFor time, +// Never asserts that the given condition is never satisfied until timeout, // periodically checking the target function at each tick. // -// [Never] is the opposite of [Eventually]. It succeeds if the waitFor timeout -// is reached without the condition ever returning true. +// [Never] is the opposite of [Eventually] ("at least once"). +// It succeeds if the timeout is reached without the condition ever returning true. // // If the parent context is cancelled before the timeout, [Never] fails. // @@ -1716,12 +1810,23 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // // assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) // +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// Use [Consistently] instead if you want to use a condition returning an error. +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// See [Eventually]. +// +// # Attention point // -// A blocking condition will cause [Never] to hang until it returns. +// See [Eventually]. // // # Examples // @@ -1729,11 +1834,11 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // 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, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.Never(t, condition, waitFor, tick, msgAndArgs...) + return assertions.Never(t, condition, timeout, tick, msgAndArgs...) } // Nil asserts that the specified object is nil. diff --git a/assert/assert_assertions_test.go b/assert/assert_assertions_test.go index 2d257ad24..dc47056c6 100644 --- a/assert/assert_assertions_test.go +++ b/assert/assert_assertions_test.go @@ -44,6 +44,33 @@ func TestCondition(t *testing.T) { }) } +func TestConsistently(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := Consistently(mock, func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond) + if !result { + t.Error("Consistently should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := Consistently(mock, func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) + if result { + t.Error("Consistently should return false on failure") + } + if !mock.failed { + t.Error("Consistently should mark test as failed") + } + }) +} + func TestContains(t *testing.T) { t.Parallel() diff --git a/assert/assert_examples_test.go b/assert/assert_examples_test.go index 017862c6f..83d8f0a2f 100644 --- a/assert/assert_examples_test.go +++ b/assert/assert_examples_test.go @@ -30,6 +30,16 @@ func ExampleCondition() { // Output: success: true } +func ExampleConsistently() { + t := new(testing.T) // should come from testing, e.g. func TestConsistently(t *testing.T) + success := assert.Consistently(t, func() bool { + return true + }, 100*time.Millisecond, 20*time.Millisecond) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} + func ExampleContains() { t := new(testing.T) // should come from testing, e.g. func TestContains(t *testing.T) success := assert.Contains(t, []string{"A", "B"}, "A") diff --git a/assert/assert_format.go b/assert/assert_format.go index c67756281..7a2cd2283 100644 --- a/assert/assert_format.go +++ b/assert/assert_format.go @@ -18,13 +18,23 @@ import ( // Conditionf is the same as [Condition], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. -func Conditionf(t T, comp Comparison, msg string, args ...any) bool { +func Conditionf(t T, comp func() bool, msg string, args ...any) bool { if h, ok := t.(H); ok { h.Helper() } return assertions.Condition(t, comp, forwardArgs(msg, args)) } +// Consistentlyf is the same as [Consistently], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func Consistentlyf[C Conditioner](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.Consistently[C](t, condition, timeout, tick, forwardArgs(msg, args)) +} + // Containsf is the same as [Contains], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. @@ -178,21 +188,21 @@ func ErrorIsf(t T, err error, target error, msg string, args ...any) bool { // Eventuallyf is the same as [Eventually], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. -func Eventuallyf(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { +func Eventuallyf[C Conditioner](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.Eventually(t, condition, waitFor, tick, forwardArgs(msg, args)) + return assertions.Eventually[C](t, condition, timeout, tick, forwardArgs(msg, args)) } // EventuallyWithf is the same as [EventuallyWith], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. -func EventuallyWithf(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { +func EventuallyWithf[C CollectibleConditioner](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.EventuallyWith(t, condition, waitFor, tick, forwardArgs(msg, args)) + return assertions.EventuallyWith[C](t, condition, timeout, tick, forwardArgs(msg, args)) } // Exactlyf is the same as [Exactly], but it accepts a format msg string to format arguments like [fmt.Printf]. @@ -738,11 +748,11 @@ func NegativeTf[SignedNumber SignedNumeric](t T, e SignedNumber, msg string, arg // Neverf is the same as [Never], but it accepts a format msg 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, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { +func Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) bool { if h, ok := t.(H); ok { h.Helper() } - return assertions.Never(t, condition, waitFor, tick, forwardArgs(msg, args)) + return assertions.Never(t, condition, timeout, tick, forwardArgs(msg, args)) } // Nilf is the same as [Nil], but it accepts a format msg string to format arguments like [fmt.Printf]. diff --git a/assert/assert_format_test.go b/assert/assert_format_test.go index ed8dab58f..c5a1cecff 100644 --- a/assert/assert_format_test.go +++ b/assert/assert_format_test.go @@ -44,6 +44,33 @@ func TestConditionf(t *testing.T) { }) } +func TestConsistentlyf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := Consistentlyf(mock, func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond, "test message") + if !result { + t.Error("Consistentlyf should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := Consistentlyf(mock, func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") + if result { + t.Error("Consistentlyf should return false on failure") + } + if !mock.failed { + t.Error("Consistentlyf should mark test as failed") + } + }) +} + func TestContainsf(t *testing.T) { t.Parallel() diff --git a/assert/assert_forward.go b/assert/assert_forward.go index db60235cf..2f5fbc07b 100644 --- a/assert/assert_forward.go +++ b/assert/assert_forward.go @@ -33,7 +33,7 @@ func New(t T) *Assertions { // Condition is the same as [Condition], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Condition(comp Comparison, msgAndArgs ...any) bool { +func (a *Assertions) Condition(comp func() bool, msgAndArgs ...any) bool { if h, ok := a.T.(H); ok { h.Helper() } @@ -43,7 +43,7 @@ func (a *Assertions) Condition(comp Comparison, msgAndArgs ...any) bool { // Conditionf is the same as [Assertions.Condition], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Conditionf(comp Comparison, msg string, args ...any) bool { +func (a *Assertions) Conditionf(comp func() bool, msg string, args ...any) bool { if h, ok := a.T.(H); ok { h.Helper() } @@ -310,46 +310,6 @@ func (a *Assertions) ErrorIsf(err error, target error, msg string, args ...any) return assertions.ErrorIs(a.T, err, target, forwardArgs(msg, args)) } -// Eventually is the same as [Eventually], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Eventually(condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.Eventually(a.T, condition, waitFor, tick, msgAndArgs...) -} - -// Eventuallyf is the same as [Assertions.Eventually], but it accepts a format msg string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) Eventuallyf(condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.Eventually(a.T, condition, waitFor, tick, forwardArgs(msg, args)) -} - -// EventuallyWith is the same as [EventuallyWith], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) EventuallyWith(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.EventuallyWith(a.T, condition, waitFor, tick, msgAndArgs...) -} - -// EventuallyWithf is the same as [Assertions.EventuallyWith], but it accepts a format msg string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and continues execution. -func (a *Assertions) EventuallyWithf(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { - if h, ok := a.T.(H); ok { - h.Helper() - } - return assertions.EventuallyWith(a.T, condition, waitFor, tick, forwardArgs(msg, args)) -} - // Exactly is the same as [Exactly], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and continues execution. @@ -1053,21 +1013,21 @@ func (a *Assertions) Negativef(e any, msg string, args ...any) bool { // 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, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +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, waitFor, tick, msgAndArgs...) + return assertions.Never(a.T, condition, timeout, tick, msgAndArgs...) } // Neverf is the same as [Assertions.Never], but it accepts a format msg 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, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool { +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, waitFor, tick, forwardArgs(msg, args)) + 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. diff --git a/assert/assert_forward_test.go b/assert/assert_forward_test.go index 7e2d87e70..14d14b900 100644 --- a/assert/assert_forward_test.go +++ b/assert/assert_forward_test.go @@ -422,64 +422,6 @@ func TestAssertionsErrorIs(t *testing.T) { }) } -func TestAssertionsEventually(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Eventually(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond) - if !result { - t.Error("Assertions.Eventually should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Eventually(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) - if result { - t.Error("Assertions.Eventually should return false on failure") - } - if !mock.failed { - t.Error("Assertions.Eventually should mark test as failed") - } - }) -} - -func TestAssertionsEventuallyWith(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.EventuallyWith(func(c *CollectT) { True(c, true) }, 100*time.Millisecond, 20*time.Millisecond) - if !result { - t.Error("Assertions.EventuallyWith should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.EventuallyWith(func(c *CollectT) { False(c, true) }, 100*time.Millisecond, 20*time.Millisecond) - if result { - t.Error("Assertions.EventuallyWith should return false on failure") - } - if !mock.failed { - t.Error("Assertions.EventuallyWith should mark test as failed") - } - }) -} - func TestAssertionsExactly(t *testing.T) { t.Parallel() @@ -2790,64 +2732,6 @@ func TestAssertionsErrorIsf(t *testing.T) { }) } -func TestAssertionsEventuallyf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Eventuallyf(func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if !result { - t.Error("Assertions.Eventuallyf should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.Eventuallyf(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if result { - t.Error("Assertions.Eventuallyf should return false on failure") - } - if !mock.failed { - t.Error("Assertions.Eventuallyf should mark test as failed") - } - }) -} - -func TestAssertionsEventuallyWithf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.EventuallyWithf(func(c *CollectT) { True(c, true) }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if !result { - t.Error("Assertions.EventuallyWithf should return true on success") - } - }) - - t.Run("failure", func(t *testing.T) { - t.Parallel() - - mock := new(mockT) - a := New(mock) - result := a.EventuallyWithf(func(c *CollectT) { False(c, true) }, 100*time.Millisecond, 20*time.Millisecond, "test message") - if result { - t.Error("Assertions.EventuallyWithf should return false on failure") - } - if !mock.failed { - t.Error("Assertions.EventuallyWithf should mark test as failed") - } - }) -} - func TestAssertionsExactlyf(t *testing.T) { t.Parallel() diff --git a/assert/assert_types.go b/assert/assert_types.go index 4bf5888ca..07c9c76be 100644 --- a/assert/assert_types.go +++ b/assert/assert_types.go @@ -32,13 +32,20 @@ type ( // should not be used outside of that context. CollectT = assertions.CollectT - // Comparison is a custom function that returns true on success and false on failure. - Comparison = assertions.Comparison + // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + CollectibleConditioner = assertions.CollectibleConditioner // ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful // for table driven tests. ComparisonAssertionFunc = assertions.ComparisonAssertionFunc + // Conditioner is a function used in asynchronous condition assertions. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + Conditioner = assertions.Conditioner + // ErrorAssertionFunc is a common function prototype when validating an error value. Can be useful // for table driven tests. ErrorAssertionFunc = assertions.ErrorAssertionFunc diff --git a/docs/LINTING.md b/docs/LINTING.md index bc67ac1c7..532cd6ad1 100644 --- a/docs/LINTING.md +++ b/docs/LINTING.md @@ -21,7 +21,7 @@ All directives are justified. No actionable items remain. ## Internalized Third-Party: `internal/spew/` -The `internal/spew/` package is an internalized copy of `go-spew`. Its 17 nolint directives +The `internal/spew/` package is an internalized copy of `go-spew`. Its 17 `nolint` directives are inherited and appropriate for a low-level reflection library: - `bypass.go:42,71,84` - `gochecknoglobals` - reflect internals, set once during init @@ -75,8 +75,8 @@ generated test cases. The 4 output files all originate from one template: |---|---|---| | `internal/assertions/error.go:273` | `errorlint` | Type switch checks for interfaces, not unwrapping errors | | `internal/assertions/compare.go:496` | `ireturn` | Generic function returning `V`; linter doesn't understand type parameter | -| `codegen/.../examples-parser/parser_test.go:15` | `gochecknoglobals` | `sync.Once` cache for parsed testdata | -| `codegen/.../comments/extractor_test.go:16` | `gochecknoglobals` | `sync.Once` cache for parsed testdata | +| `codegen/.../examples-parser/parser_test.go:15` | `gochecknoglobals` | `sync.Once` cache for parsed test data | +| `codegen/.../comments/extractor_test.go:16` | `gochecknoglobals` | `sync.Once` cache for parsed test data | Note: the `thelper` linter was disabled entirely (see History) — it previously accounted for 12 false positives on test case factories returning `func(*testing.T)`. @@ -111,8 +111,8 @@ locale-specific or escape-sequence behavior. | File | Directive | Reason | |---|---|---| -| `codegen/.../examples-parser/parser_test.go:15` | `gochecknoglobals` | `sync.Once` cache for parsed testdata | -| `codegen/.../comments/extractor_test.go:16` | `gochecknoglobals` | `sync.Once` cache for parsed testdata | +| `codegen/.../examples-parser/parser_test.go:15` | `gochecknoglobals` | `sync.Once` cache for parsed test data | +| `codegen/.../comments/extractor_test.go:16` | `gochecknoglobals` | `sync.Once` cache for parsed test data | | `codegen/.../testdata/examplespkg/examplespkg.go:21` | `unused` | Fixture: verifies unexported symbols are skipped | ## Inherent Type-Switch Complexity diff --git a/docs/doc-site/_index.md b/docs/doc-site/_index.md index f500607da..a2cb6c11e 100644 --- a/docs/doc-site/_index.md +++ b/docs/doc-site/_index.md @@ -155,7 +155,7 @@ See also our [CONTRIBUTING guidelines](./project/contributing/CONTRIBUTING.md). - [Examples](./usage/EXAMPLES.md) - Practical code examples for common testing scenarios **Advanced Topics:** -- [Generics Guide](./usage/GENERICS.md) - Type-safe assertions with 38 generic functions +- [Generics Guide](./usage/GENERICS.md) - Type-safe assertions with generic functions - [Migration Guide](./usage/MIGRATION.md) - Migrating from stretchr/testify v1 - [Changes from v1](./usage/CHANGES.md) - All changes and improvements in v2 - [Benchmarks](./project/maintainers/benchmarks.md) - Performance improvements in v2 diff --git a/docs/doc-site/api/_index.md b/docs/doc-site/api/_index.md index b5a45daf3..e2b6de88e 100644 --- a/docs/doc-site/api/_index.md +++ b/docs/doc-site/api/_index.md @@ -34,7 +34,7 @@ Each domain contains assertions regrouped by their use case (e.g. http, json, er - [Boolean](./boolean.md) - Asserting Boolean Values (4) - [Collection](./collection.md) - Asserting Slices And Maps (19) - [Comparison](./comparison.md) - Comparing Ordered Values (12) -- [Condition](./condition.md) - Expressing Assertions Using Conditions (4) +- [Condition](./condition.md) - Expressing Assertions Using Conditions (5) - [Equality](./equality.md) - Asserting Two Things Are Equal (16) - [Error](./error.md) - Asserting Errors (8) - [File](./file.md) - Asserting OS Files (6) diff --git a/docs/doc-site/api/condition.md b/docs/doc-site/api/condition.md index 0b5186302..cbf6d8596 100644 --- a/docs/doc-site/api/condition.md +++ b/docs/doc-site/api/condition.md @@ -7,6 +7,8 @@ domains: keywords: - "Condition" - "Conditionf" + - "Consistently" + - "Consistentlyf" - "Eventually" - "Eventuallyf" - "EventuallyWith" @@ -24,17 +26,19 @@ Expressing Assertions Using Conditions _All links point to _ -This domain exposes 4 functionalities. +This domain exposes 5 functionalities. +Generic assertions are marked with a {{% icon icon="star" color=orange %}}. ```tree - [Condition](#condition) | angles-right -- [Eventually](#eventually) | angles-right -- [EventuallyWith](#eventuallywith) | angles-right +- [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 ``` ### Condition{#condition} -Condition uses a [Comparison](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Comparison) to assert a complex condition. +Condition uses a comparison function to assert a complex condition. {{% expand title="Examples" %}} {{< tabs >}} @@ -125,49 +129,386 @@ func main() { {{% tab title="assert" style="secondary" %}} | Signature | Usage | |--|--| -| [`assert.Condition(t T, comp Comparison, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Condition) | package-level function | -| [`assert.Conditionf(t T, comp Comparison, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Conditionf) | formatted variant | -| [`assert.(*Assertions).Condition(comp Comparison) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Condition) | method variant | -| [`assert.(*Assertions).Conditionf(comp Comparison, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Conditionf) | method formatted variant | +| [`assert.Condition(t T, comp func() bool, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Condition) | package-level function | +| [`assert.Conditionf(t T, comp func() bool, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Conditionf) | formatted variant | +| [`assert.(*Assertions).Condition(comp func() bool) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Condition) | method variant | +| [`assert.(*Assertions).Conditionf(comp func() bool, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Conditionf) | method formatted variant | {{% /tab %}} {{% tab title="require" style="secondary" %}} | Signature | Usage | |--|--| -| [`require.Condition(t T, comp Comparison, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Condition) | package-level function | -| [`require.Conditionf(t T, comp Comparison, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Conditionf) | formatted variant | -| [`require.(*Assertions).Condition(comp Comparison) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Condition) | method variant | -| [`require.(*Assertions).Conditionf(comp Comparison, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Conditionf) | method formatted variant | +| [`require.Condition(t T, comp func() bool, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Condition) | package-level function | +| [`require.Conditionf(t T, comp func() bool, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Conditionf) | formatted variant | +| [`require.(*Assertions).Condition(comp func() bool) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Condition) | method variant | +| [`require.(*Assertions).Conditionf(comp func() bool, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Conditionf) | method formatted variant | {{% /tab %}} {{% tab title="internal" style="accent" icon="wrench" %}} | Signature | Usage | |--|--| -| [`assertions.Condition(t T, comp Comparison, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Condition) | internal implementation | +| [`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) {{% /tab %}} {{< /tabs >}} -### Eventually{#eventually} -Eventually asserts that the given condition will be met in waitFor time, +### Consistently[C Conditioner] {{% icon icon="star" color=orange %}}{#consistentlyc-conditioner} +Consistently asserts that the given condition is always satisfied until timeout, +periodically checking the target function at each tick. + +[Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) ("always") imposes a stronger constraint than [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) ("at least once"): +it checks at every tick that every occurrence of the condition is satisfied, whereas +[Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) succeeds on the first occurrence of a successful condition. + +#### Alternative condition signature + +The simplest form of condition is: + + func() bool + +The semantics of the assertion are "always returns true". + +To build more complex cases, a condition may also be defined as: + + func(context.Context) error + +It fails as soon as an error is returned before timeout expressing "always returns no error (nil)" + +This is consistent with [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) expressing "eventually returns no error (nil)". + +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. + +#### Concurrency + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +#### Attention point + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% 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. + success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond + failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% 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" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestConsistently(t *testing.T) + success := assert.Consistently(t, func() bool { + return true + }, 100*time.Millisecond, 20*time.Millisecond) + fmt.Printf("success: %t\n", success) + +} + +``` +{{% /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 ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // Simulate a service that stays healthy. + healthCheck := func(_ context.Context) error { + return nil // always healthy + } + + result := assert.Consistently(t, healthCheck, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently healthy: %t", result) + +} + +``` +{{% /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 + + // A counter that stays within bounds during the test. + var counter atomic.Int32 + counter.Store(5) + + result := assert.Consistently(t, func() bool { + return counter.Load() < 10 + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently under limit: %t", result) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% 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" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestConsistently(t *testing.T) + require.Consistently(t, func() bool { + return true + }, 100*time.Millisecond, 20*time.Millisecond) + fmt.Println("passed") + +} + +``` +{{% /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 ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // Simulate a service that stays healthy. + healthCheck := func(_ context.Context) error { + return nil // always healthy + } + + require.Consistently(t, healthCheck, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently healthy: %t", !t.Failed()) + +} + +``` +{{% /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 + + // A counter that stays within bounds during the test. + var counter atomic.Int32 + counter.Store(5) + + require.Consistently(t, func() bool { + return counter.Load() < 10 + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently under limit: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.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/assert#Consistently) | package-level function | +| [`assert.Consistentlyf[C Conditioner](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#Consistentlyf) | formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.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/require#Consistently) | package-level function | +| [`require.Consistentlyf[C Conditioner](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#Consistentlyf) | formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`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) +{{% /tab %}} +{{< /tabs >}} + +### Eventually[C Conditioner] {{% icon icon="star" color=orange %}}{#eventuallyc-conditioner} +Eventually asserts that the given condition will be met before timeout, periodically checking the target function on each tick. -[Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) waits until the condition returns true, for at most waitFor, +[Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) waits until the condition returns true, at most until timeout, or until the parent context of the test is cancelled. -If the condition takes longer than waitFor to complete, [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) fails +If the condition takes longer than the timeout to complete, [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) fails but waits for the current condition execution to finish before returning. For long-running conditions to be interrupted early, check [testing.T.Context](https://pkg.go.dev/testing#T.Context) which is cancelled on test failure. +#### Alternative condition signature + +The simplest form of condition is: + + func() bool + +To build more complex cases, a condition may also be defined as: + + func(context.Context) error + +It fails when an error has always been returned up to timeout (equivalent semantics to func() bool returns false), +expressing "eventually returns no error (nil)". + +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. + +The semantics of the three available async assertions read as follows. + + - [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) (func() bool) : "eventually returns true" + + - [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) (func() bool) : "never returns true" + + - [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) (func() bool): "always returns true" + + - [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) (func(ctx) error) : "eventually returns nil" + + - [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) (func(ctx) error) : not supported, use [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) instead (avoids confusion with double negation) + + - [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) (func(ctx) error): "always returns nil" + #### Concurrency -The condition function is never executed in parallel: only one goroutine executes it. -It may write to variables outside its scope without triggering race conditions. +The condition function is always executed serially by a single goroutine. It is always executed at least once. + +It may thus write to variables outside its scope without triggering race conditions. A blocking condition will cause [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) to hang until it returns. +Notice that time ticks may be skipped if the condition takes longer than the tick interval. + +#### Attention point + +Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +counter-intuitive results, such as ticks or timeouts not firing in time as expected. + +To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << +timeout). + {{% expand title="Examples" %}} {{< tabs >}} {{% tab title="Usage" %}} @@ -210,6 +551,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 ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // Simulate an async operation that completes after a short delay. + var ready atomic.Bool + go func() { + time.Sleep(30 * time.Millisecond) + ready.Store(true) + }() + + result := assert.Eventually(t, ready.Load, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually ready: %t", result) + +} + +``` +{{% /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 + + // Simulate a service that becomes healthy after a few attempts. + 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, healthCheck, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually healthy: %t", result) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -247,6 +671,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 ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // Simulate an async operation that completes after a short delay. + var ready atomic.Bool + go func() { + time.Sleep(30 * time.Millisecond) + ready.Store(true) + }() + + require.Eventually(t, ready.Load, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually ready: %t", !t.Failed()) + +} + +``` +{{% /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 + + // Simulate a service that becomes healthy after a few attempts. + 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, healthCheck, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually healthy: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -259,31 +766,27 @@ func main() { {{% tab title="assert" style="secondary" %}} | Signature | Usage | |--|--| -| [`assert.Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) | package-level function | -| [`assert.Eventuallyf(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventuallyf) | formatted variant | -| [`assert.(*Assertions).Eventually(condition func() bool, waitFor time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventually) | method variant | -| [`assert.(*Assertions).Eventuallyf(condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventuallyf) | method formatted variant | +| [`assert.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/assert#Eventually) | package-level function | +| [`assert.Eventuallyf[C Conditioner](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#Eventuallyf) | formatted variant | {{% /tab %}} {{% tab title="require" style="secondary" %}} | Signature | Usage | |--|--| -| [`require.Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Eventually) | package-level function | -| [`require.Eventuallyf(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Eventuallyf) | formatted variant | -| [`require.(*Assertions).Eventually(condition func() bool, waitFor time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Eventually) | method variant | -| [`require.(*Assertions).Eventuallyf(condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Eventuallyf) | method formatted variant | +| [`require.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/require#Eventually) | package-level function | +| [`require.Eventuallyf[C Conditioner](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#Eventuallyf) | formatted variant | {{% /tab %}} {{% tab title="internal" style="accent" icon="wrench" %}} | Signature | Usage | |--|--| -| [`assertions.Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Eventually) | internal implementation | +| [`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#L67) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Eventually](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L108) {{% /tab %}} {{< /tabs >}} -### EventuallyWith{#eventuallywith} -EventuallyWith asserts that the given condition will be met in waitFor time, +### EventuallyWith[C CollectibleConditioner] {{% icon icon="star" color=orange %}}{#eventuallywithc-collectibleconditioner} +EventuallyWith asserts that the given condition will be met before the timeout, periodically checking the target function at each tick. In contrast to [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually), the condition function is supplied with a [CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#CollectT) @@ -292,10 +795,10 @@ to accumulate errors from calling other assertions. The condition is considered "met" if no errors are raised in a tick. The supplied [CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#CollectT) collects all errors from one tick. -If the condition is not met before waitFor, the collected errors from the +If the condition is not met before the timeout, the collected errors from the last tick are copied to t. -Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) cancels the condition immediately and fails the assertion. +Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) cancels the condition immediately and causes the assertion to fail. #### Concurrency @@ -406,50 +909,56 @@ func main() { {{% tab title="assert" style="secondary" %}} | Signature | Usage | |--|--| -| [`assert.EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#EventuallyWith) | package-level function | -| [`assert.EventuallyWithf(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#EventuallyWithf) | formatted variant | -| [`assert.(*Assertions).EventuallyWith(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWith) | method variant | -| [`assert.(*Assertions).EventuallyWithf(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWithf) | method formatted variant | +| [`assert.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/assert#EventuallyWith) | package-level function | +| [`assert.EventuallyWithf[C CollectibleConditioner](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#EventuallyWithf) | formatted variant | {{% /tab %}} {{% tab title="require" style="secondary" %}} | Signature | Usage | |--|--| -| [`require.EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#EventuallyWith) | package-level function | -| [`require.EventuallyWithf(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#EventuallyWithf) | formatted variant | -| [`require.(*Assertions).EventuallyWith(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.EventuallyWith) | method variant | -| [`require.(*Assertions).EventuallyWithf(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.EventuallyWithf) | method formatted variant | +| [`require.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/require#EventuallyWith) | package-level function | +| [`require.EventuallyWithf[C CollectibleConditioner](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#EventuallyWithf) | formatted variant | {{% /tab %}} {{% tab title="internal" style="accent" icon="wrench" %}} | Signature | Usage | |--|--| -| [`assertions.EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith) | internal implementation | +| [`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#L148) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#EventuallyWith](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L253) {{% /tab %}} {{< /tabs >}} ### Never{#never} -Never asserts that the given condition is never satisfied within waitFor time, +Never asserts that the given condition is never satisfied until timeout, periodically checking the target function at each tick. -[Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) is the opposite of [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). It succeeds if the waitFor timeout -is reached without the condition ever returning true. +[Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) is the opposite of [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) ("at least once"). +It succeeds if the timeout is reached without the condition ever returning true. If the parent context is cancelled before the timeout, [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) fails. +#### Alternative condition signature + +The simplest form of condition is: + + func() bool + +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. + #### Concurrency -The condition function is never executed in parallel: only one goroutine executes it. -It may write to variables outside its scope without triggering race conditions. +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). -A blocking condition will cause [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) to hang until it returns. +#### Attention point + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). {{% expand title="Examples" %}} {{< tabs >}} {{% 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. success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond ``` @@ -487,6 +996,47 @@ 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" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A channel that should remain empty during the test. + events := make(chan struct{}, 1) + + result := assert.Never(t, func() bool { + select { + case <-events: + return true // event received = condition becomes true = Never fails + default: + return false + } + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("never received: %t", result) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -524,6 +1074,47 @@ 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" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A channel that should remain empty during the test. + events := make(chan struct{}, 1) + + require.Never(t, func() bool { + select { + case <-events: + return true // event received = condition becomes true = Never fails + default: + return false + } + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("never received: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + {{% /cards %}} {{< /tab >}} @@ -536,26 +1127,26 @@ func main() { {{% tab title="assert" style="secondary" %}} | Signature | Usage | |--|--| -| [`assert.Never(t T, condition func() bool, waitFor 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, waitFor 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, waitFor 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, waitFor 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(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 | {{% /tab %}} {{% tab title="require" style="secondary" %}} | Signature | Usage | |--|--| -| [`require.Never(t T, condition func() bool, waitFor 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, waitFor 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, waitFor 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, waitFor 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(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 | {{% /tab %}} {{% tab title="internal" style="accent" icon="wrench" %}} | Signature | Usage | |--|--| -| [`assertions.Never(t T, condition func() bool, waitFor 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(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#L99) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L151) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/project/maintainers/ROADMAP.md b/docs/doc-site/project/maintainers/ROADMAP.md index 0364a6f60..aa3497c62 100644 --- a/docs/doc-site/project/maintainers/ROADMAP.md +++ b/docs/doc-site/project/maintainers/ROADMAP.md @@ -33,9 +33,8 @@ timeline : more documentation and examples ⏳v2.4 (Mar 2026) : Stabilize API (no more removals) : NoFileDescriptorLeak (unix) - : async: Eventually/Never to accept error and context - : JSONPointerT - : export internal tools (spew, difflib, + : async: Eventually/Never to accept error and context, Consistently + : export internal tools (spew, difflib) section Q2 2026 📝 v2.5 (May 2026) : New candidate features from upstream : NoFileDescriptorLeak (windows port) @@ -72,7 +71,7 @@ We actively monitor [github.com/stretchr/testify](https://github.com/stretchr/te **Review frequency**: Quarterly (next review: April 2026) -**Processed items**: 28 upstream PRs and issues have been reviewed, with 21 implemented/merged, 5 superseded by our implementation or merely marked as informational, and 2 currently under consideration. +**Processed items**: 31 upstream PRs and issues have been reviewed, with 23 implemented/merged, 4 superseded by our implementation, 2 informational, and 2 currently under consideration. For a complete catalog of all upstream PRs and issues we've processed (implemented, adapted, superseded, or monitoring), see the [Upstream Tracking](../../usage/TRACKING.md). diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index a348feead..25d858bb2 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -8,7 +8,7 @@ weight: 15 **Key Changes:** - ✅ **Zero Dependencies**: Completely self-contained -- ✅ **New functions**: 56 additional assertions (42 generic + 14 reflection-based) +- ✅ **New functions**: 58 additional assertions (43 generic + 15 reflection-based) - ✅ **Performance**: ~10x for generic variants (from 1.2x to 81x, your mileage may vary) - ✅ **Breaking changes**: Requires go1.24, removed suites, mocks, http tooling, and deprecated functions. YAMLEq becomes optional (panics by default). @@ -54,7 +54,7 @@ See also a quick [migration guide](./MIGRATION.md). | Change | Origin | Description | |--------|--------|-------------| -| **Code generation** | Design goal | 100% generated assert/require packages (608+ functions from 76 assertions) | +| **Code generation** | Design goal | 100% generated assert/require packages (840 functions from 127 assertions) | | **Code modernization** | Design goal | Relinted, refactored and modernized the code base, including internalized difflib and go-spew| | **Refactored tests** | Design goal | Full refactoring of tests on assertion functions, with unified test scenarios for reflection-based/generic assertions | @@ -205,7 +205,14 @@ See also a quick [migration guide](./MIGRATION.md). ### Condition -**New functions**: None +#### New Function (1) + +| Function | Type Parameters | Description | +|----------|-----------------|-------------| +| `Consistently[C Conditioner]` | `func() bool` or `func(context.Context) error` | async assertion to express "always true" (adapted proposal [#1606], [#1087]) | + +[#1087]: https://github.com/stretchr/testify/issues/1087 +[#1606]: https://github.com/stretchr/testify/pulls/1606 #### ⚠️ Behavior Changes @@ -214,16 +221,24 @@ See also a quick [migration guide](./MIGRATION.md). | Fixed goroutine leak | [#1611] | Consolidated `Eventually`, `Never`, and `EventuallyWith` into single `pollCondition` function | | Context-based polling | Internal refactoring | Reimplemented with context-based approach for better resource management | | Unified implementation | Internal refactoring | Single implementation eliminates code duplication and prevents resource leaks | -| **Renaming** | `EventuallyWithT` renamed into `EventuallyWith` (conflicted with the convention adopted for generics) | +| `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) | **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. Callers should **NOT** assume that the call to `Eventually` or `Never` exits before the condition is evaluated. -**Supersedes**: This implementation also supersedes upstream proposals [#1819] (handle unexpected exits) and [#1830] (CollectT.Halt) with a more comprehensive solution. +**Supersedes**: This implementation also supersedes upstream proposals [#1819] (handle unexpected exits) and [#1830] (`CollectT.Halt`) with a more comprehensive solution. [#1611]: https://github.com/stretchr/testify/issues/1611 [#1819]: https://github.com/stretchr/testify/pull/1819 [#1830]: https://github.com/stretchr/testify/pull/1830 +### Breaking Changes + +| Change | Origin | Description | +|--------|--------|-------------| +| **Renaming** | Internal refactorting | `EventuallyWithT` renamed into `EventuallyWith` (conflicted with the convention adopted for generics) | +| **Removal** | API simplification | `Comparison` type is removed as a mere alias to `func() bool` | + ### Equality {{% expand title="Generics" %}} @@ -368,15 +383,18 @@ Removed extraneous type declaration `PanicTestFunc` (`func()`). | Function | Type | Description | |----------|------|-------------| | `NoGoRoutineLeak` | Reflection | Assert that no goroutines leak from a tested function | +| `NoFileDescriptorLeak` | Reflection | Assert that no file descriptors leak from a tested function (Linux) | #### Implementation -Uses **pprof labels** instead of stack-trace heuristics (like `go.uber.org/goleak`): +`NoGoRoutineLeak` uses **pprof labels** instead of stack-trace heuristics (like `go.uber.org/goleak`): - Only goroutines spawned by the tested function are checked - Pre-existing goroutines (runtime, pools, parallel tests) are ignored automatically - No configuration or filter lists needed - Works safely with `t.Parallel()` +`NoFileDescriptorLeak` compares open file descriptors before and after the tested function (Linux only, via `/proc/self/fd`). + See [Examples](./EXAMPLES.md#goroutine-leak-detection) for usage patterns. ### String @@ -551,10 +569,9 @@ github.com/go-openapi/testify/v2 # Core (zero deps) [go.mod] | Metric | Value | |--------|-------| -| **New functions** | 56 (42 generic + 14 reflection) | -| **Total assertions** | 128 base assertions | -| **Generated functions** | ~800 (see [the -maths](../project/maintainers/ARCHITECTURE.md#the-maths-with-assertion-variants) +| **New functions** | 58 (43 generic + 15 reflection) | +| **Total assertions** | 127 base assertions | +| **Generated functions** | 840 (see [the maths](../project/maintainers/ARCHITECTURE.md#the-maths-with-assertion-variants)) | | **Generic coverage** | 10 domains (10/19) | | **Performance improvement** | 1.2x to 81x faster | | **Dependencies** | 0 external (was 2 required) | @@ -566,7 +583,7 @@ maths](../project/maintainers/ARCHITECTURE.md#the-maths-with-assertion-variants) ## See Also - [Migration Guide](./MIGRATION.md) - Step-by-step guide to migrating from testify v1 -- [Generics Guide](./GENERICS.md) - Detailed documentation of all 38 generic assertions +- [Generics Guide](./GENERICS.md) - Detailed documentation of all 43 generic assertions - [Performance Benchmarks](../project/maintainers/BENCHMARKS.md) - Comprehensive performance analysis - [Examples](./EXAMPLES.md) - Practical usage examples showing new features - [Tutorial](./TUTORIAL.md) - Best practices for writing tests with testify v2 diff --git a/docs/doc-site/usage/MIGRATION.md b/docs/doc-site/usage/MIGRATION.md index d97f95a02..f068472d3 100644 --- a/docs/doc-site/usage/MIGRATION.md +++ b/docs/doc-site/usage/MIGRATION.md @@ -154,7 +154,8 @@ go test -v -testify.colorized -testify.theme=light . #### 4. Optional: Adopt Generic Assertions -For better type safety and performance, consider migrating to generic assertion variants. This is entirely optional—reflection-based assertions continue to work as before. +For better type safety and performance, consider migrating to generic assertion variants. +This is entirely optional: reflection-based assertions continue to work as before. ##### Identify Generic-Capable Assertions @@ -311,6 +312,6 @@ Make sure to check the [behavior changes](./CHANGES.md) as we have fixed a few q - [Changes from v1](./CHANGES.md) - Complete list of all changes, fixes, and new features - [Examples](./EXAMPLES.md) - Practical examples showing v2 usage patterns -- [Generics Guide](./GENERICS.md) - Learn about the 38 new type-safe generic assertions +- [Generics Guide](./GENERICS.md) - Learn about the 43 new type-safe generic assertions - [Usage Guide](./USAGE.md) - API conventions and how to navigate the documentation - [Tutorial](./TUTORIAL.md) - Best practices for writing tests with testify v2 diff --git a/docs/doc-site/usage/TRACKING.md b/docs/doc-site/usage/TRACKING.md index deeecad08..a05663b7e 100644 --- a/docs/doc-site/usage/TRACKING.md +++ b/docs/doc-site/usage/TRACKING.md @@ -15,9 +15,9 @@ We continue to monitor and selectively adopt changes from the upstream repositor - ✅ [#1685] - Partial iterator support (SeqContainsT variants) - ✅ [#1828] - Spew panic fixes - ✅ [#1825], [#1818], [#1223], [#1813], [#1611], [#1822], [#1829] - Various bug fixes +- ✅ [#1606], [#1087] - Consistently assertion ### Monitoring -- 🔍 [#1087] - Consistently assertion - 🔍 [#1601] - NoFieldIsZero - 🔍 [#1840] - JSON presence check without exact values @@ -80,6 +80,8 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1829] | Issue | Fix time.Time rendering in diffs | ✅ Fixed in internalized go-spew | | [#1611] | Issue | Goroutine leak in Eventually/Never | ✅ Fixed by using context.Context (consolidation into single pollCondition function) | | [#1813] | Issue | Panic with unexported fields | ✅ Fixed via #1828 in internalized spew | +| [#1087] | Issue | Consistently assertion | ✅ Adapted | +| [#1606] | PR | Consistently assertion | ✅ Adapted | [#994]: https://github.com/stretchr/testify/pull/994 [#1232]: https://github.com/stretchr/testify/pull/1232 @@ -91,6 +93,8 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#1816]: https://github.com/stretchr/testify/issues/1816 [#1826]: https://github.com/stretchr/testify/issues/1826 [#1829]: https://github.com/stretchr/testify/issues/1829 +[#1087]: https://github.com/stretchr/testify/issues/1087 +[#1606]: https://github.com/stretchr/testify/pull/1606 ### Superseded by Our Implementation @@ -108,7 +112,6 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Reference | Type | Summary | Status | |-----------|------|---------|--------| -| [#1087] | PR | Consistently assertion | 🔍 Monitoring - Evaluating usefulness | | [#1601] | Issue | NoFieldIsZero assertion | 🔍 Monitoring - Considering implementation | | [#1840] | Issue | JSON presence check without exact values | 🔍 Monitoring - Interesting for testing APIs with generated IDs | @@ -116,7 +119,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Reference | Type | Summary | Outcome | |-----------|------|---------|---------| -| [#1147] | Issue | General discussion about generics adoption | ℹ️ Marked "Not Planned" upstream - We implemented our own generics approach (38 functions) | +| [#1147] | Issue | General discussion about generics adoption | ℹ️ Marked "Not Planned" upstream - We implemented our own generics approach (42 functions) | | [#1308] | PR | Comprehensive refactor with generic type parameters | ℹ️ Draft for v2.0.0 upstream - We took a different approach with the same objective | [#1147]: https://github.com/stretchr/testify/issues/1147 @@ -126,11 +129,11 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Category | Count | |----------|-------| -| **Implemented/Merged** | 21 | +| **Implemented/Merged** | 23 | | **Superseded** | 4 | -| **Monitoring** | 3 | +| **Monitoring** | 2 | | **Informational** | 2 | -| **Total Processed** | 30 | +| **Total Processed** | 31 | **Note**: This fork maintains an active relationship with upstream, regularly reviewing new PRs and issues. The quarterly review process ensures we stay informed about upstream developments while maintaining our architectural independence. diff --git a/hack/doc-site/hugo/.gitignore b/hack/doc-site/hugo/.gitignore index 188dbbb1f..af441e510 100644 --- a/hack/doc-site/hugo/.gitignore +++ b/hack/doc-site/hugo/.gitignore @@ -1,2 +1,3 @@ public *.lock +testify.yaml diff --git a/hack/doc-site/hugo/README.md b/hack/doc-site/hugo/README.md index f16c1fe36..066c0c059 100644 --- a/hack/doc-site/hugo/README.md +++ b/hack/doc-site/hugo/README.md @@ -8,7 +8,7 @@ This directory contains the Hugo configuration for the Testify documentation sit hugo/ ├── hugo.yaml # Main Hugo configuration ├── testify.yaml.template # Dynamic config template (version info) -├── gendoc.sh # Local development server script +├── gendoc.go # Local development server (go run gendoc.go) ├── themes/ │ ├── hugo-relearn/ # Relearn theme (extracted from zip) │ ├── testify-assets/ # Custom SCSS and assets @@ -33,13 +33,13 @@ This directory is mounted as Hugo's content directory via module mounts in `hugo ```bash # Run local Hugo server -./gendoc.sh +go run gendoc.go # Site will be available at: # http://localhost:1313/testify/ ``` -The `gendoc.sh` script: +The `gendoc.go` program: 1. Extracts version info from git tags and go.mod 2. Generates `testify.yaml` from template 3. Starts Hugo server with both config files diff --git a/hack/doc-site/hugo/gendoc.go b/hack/doc-site/hugo/gendoc.go new file mode 100644 index 000000000..7ffdc0244 --- /dev/null +++ b/hack/doc-site/hugo/gendoc.go @@ -0,0 +1,140 @@ +//go:build ignore + +// Local development script for Hugo documentation. +// +// Usage: go run gendoc.go +// +// Requires: +// * hugo +// * git +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" +) + +//nolint:forbidigo,dogsled +func main() { + ctx := context.Background() + + // Change to the directory containing this script. + _, thisFile, _, _ := runtime.Caller(0) + scriptDir := filepath.Dir(thisFile) + if err := os.Chdir(scriptDir); err != nil { + fatalf("chdir: %v", err) + } + + fmt.Println("==> Preparing Hugo documentation site...") + + latestRelease := gitLatestRelease(ctx) + requiredGoVersion := goVersionFromMod() + buildTime := time.Now().UTC().Format("2006-01-02T15:04:05Z") + versionMessage := "Documentation test for latest master" + + fmt.Printf(" Latest release: %s\n", latestRelease) + fmt.Printf(" Go version: %s\n", requiredGoVersion) + fmt.Printf(" Build time: %s\n", buildTime) + + // Generate dynamic config from template. + generateTestifyYAML(requiredGoVersion, latestRelease, versionMessage, buildTime) + fmt.Println("==> Generated testify.yaml") + + // Check if theme exists. + if _, err := os.Stat("themes/hugo-relearn"); os.IsNotExist(err) { + fatalf("Relearn theme not found at themes/hugo-relearn\n" + + "Run: unzip hugo-theme-relearn-main.zip -d themes/ && mv themes/hugo-theme-relearn-main themes/hugo-relearn") + } + + // Check if generated docs exist. + if _, err := os.Stat("../../../docs/doc-site"); os.IsNotExist(err) { + fmt.Println("WARNING: Generated docs not found at ../../../docs/doc-site") + fmt.Println("You may need to run: go generate ./...") + fmt.Println() + fmt.Println("Creating placeholder content directory...") + os.MkdirAll("content", 0o755) //nolint:errcheck,mnd + } + + fmt.Println("==> Starting Hugo development server...") + fmt.Println(" Visit: http://localhost:1313/testify/") + fmt.Println() + + // Start Hugo server with both configs. + cmd := exec.CommandContext(ctx, "hugo", "server", + "--config", "hugo.yaml,testify.yaml", + "--buildDrafts", + "--disableFastRender", + "--navigateToChanged", + "--bind", "0.0.0.0", + "--port", "1313", + "--baseURL", "http://localhost:1313/testify/", + "--appendPort=false", + "--logLevel", "info", + "--cleanDestinationDir", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + fatalf("hugo: %v", err) + } +} + +// gitLatestRelease returns the latest semver tag, or "dev" if none found. +func gitLatestRelease(ctx context.Context) string { + out, err := exec.CommandContext(ctx, "git", "tag", "--list", "--sort", "-version:refname", "v*").Output() + if err != nil || len(out) == 0 { + return "dev" + } + sc := bufio.NewScanner(strings.NewReader(string(out))) + if sc.Scan() { + return strings.TrimSpace(sc.Text()) + } + return "dev" +} + +// goVersionFromMod extracts the go version from the root go.mod. +func goVersionFromMod() string { + data, err := os.ReadFile("../../../go.mod") + if err != nil { + fatalf("reading go.mod: %v", err) + } + re := regexp.MustCompile(`(?m)^go\s+(\S+)`) + m := re.FindSubmatch(data) + if m == nil { + fatalf("could not find go version in go.mod") + } + return string(m[1]) +} + +// generateTestifyYAML reads the template and writes testify.yaml with substitutions. +func generateTestifyYAML(goVersion, latestRelease, versionMessage, buildTime string) { + tmpl, err := os.ReadFile("testify.yaml.template") + if err != nil { + fatalf("reading template: %v", err) + } + + out := string(tmpl) + out = strings.ReplaceAll(out, "{{ GO_VERSION }}", goVersion) + out = strings.ReplaceAll(out, "{{ LATEST_RELEASE }}", latestRelease) + out = strings.ReplaceAll(out, "{{ VERSION_MESSAGE }}", versionMessage) + out = strings.ReplaceAll(out, "{{ BUILD_TIME }}", buildTime) + + if err := os.WriteFile("testify.yaml", []byte(out), 0o600); err != nil { //nolint:mnd + fatalf("writing testify.yaml: %v", err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...) + os.Exit(1) +} diff --git a/hack/doc-site/hugo/gendoc.sh b/hack/doc-site/hugo/gendoc.sh deleted file mode 100755 index b897068ad..000000000 --- a/hack/doc-site/hugo/gendoc.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Local development script for Hugo documentation - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "${SCRIPT_DIR}" - -echo "==> Preparing Hugo documentation site..." - -# Extract version information -LATEST_RELEASE=$(git tag --list --sort -version:refname 'v*' 2>/dev/null | head -1 || echo "dev") -REQUIRED_GO_VERSION=$(grep "^go\s" ../../../go.mod | awk '{print $2}') -BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -VERSION_MESSAGE="Documentation test for latest master" - -echo " Latest release: ${LATEST_RELEASE}" -echo " Go version: ${REQUIRED_GO_VERSION}" -echo " Build time: ${BUILD_TIME}" - -# Generate dynamic config -cat testify.yaml.template \ - | sed "s|{{ GO_VERSION }}|${REQUIRED_GO_VERSION}|g" \ - | sed "s|{{ LATEST_RELEASE }}|${LATEST_RELEASE}|g" \ - | sed "s|{{ VERSION_MESSAGE }}|${VERSION_MESSAGE}|g" \ - | sed "s|{{ BUILD_TIME }}|${BUILD_TIME}|g" \ - > testify.yaml - -echo "==> Generated testify.yaml" - -# Check if theme exists -if [ ! -d "themes/hugo-relearn" ]; then - echo "ERROR: Relearn theme not found at themes/hugo-relearn" - echo "Run: unzip hugo-theme-relearn-main.zip -d themes/ && mv themes/hugo-theme-relearn-main themes/hugo-relearn" - exit 1 -fi - -# Check if generated docs exist -if [ ! -d "../../../docs/doc-site" ]; then - echo "WARNING: Generated docs not found at ../../../docs/doc-site" - echo "You may need to run: go generate ./..." - echo "" - echo "Creating placeholder content directory..." - mkdir -p content -fi - -echo "==> Starting Hugo development server..." -echo " Visit: http://localhost:1313/testify/" -echo "" - -# Start Hugo server with both configs -hugo server \ - --config hugo.yaml,testify.yaml \ - --buildDrafts \ - --disableFastRender \ - --navigateToChanged \ - --bind 0.0.0.0 \ - --port 1313 \ - --baseURL http://localhost:1313/testify/ \ - --appendPort=false \ - --logLevel info \ - --cleanDestinationDir diff --git a/hack/doc-site/hugo/testify.yaml b/hack/doc-site/hugo/testify.yaml deleted file mode 100644 index 3ed7f3d9b..000000000 --- a/hack/doc-site/hugo/testify.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Dynamic configuration generated at build time -# This file provides version information extracted from the repository - -params: - testify: - # Go version requirement (from go.mod) - goVersion: '1.24.0' - - # Latest release tag (from git tags) - latestRelease: 'v2.2.0' - - # Version message for the documentation set - versionMessage: 'Documentation test for latest master' - - # Build timestamp - buildTime: '2026-02-06T17:48:01Z' diff --git a/internal/assertions/condition.go b/internal/assertions/condition.go index 844398b19..8cde3fd89 100644 --- a/internal/assertions/condition.go +++ b/internal/assertions/condition.go @@ -13,7 +13,7 @@ import ( "time" ) -// Condition uses a [Comparison] to assert a complex condition. +// Condition uses a comparison function to assert a complex condition. // // # Usage // @@ -23,7 +23,7 @@ import ( // // success: func() bool { return true } // failure: func() bool { return false } -func Condition(t T, comp Comparison, msgAndArgs ...any) bool { +func Condition(t T, comp func() bool, msgAndArgs ...any) bool { // Domain: condition if h, ok := t.(H); ok { h.Helper() @@ -37,13 +37,13 @@ func Condition(t T, comp Comparison, msgAndArgs ...any) bool { return result } -// Eventually asserts that the given condition will be met in waitFor time, +// Eventually asserts that the given condition will be met before timeout, // periodically checking the target function on each tick. // -// [Eventually] waits until the condition returns true, for at most waitFor, +// [Eventually] waits until the condition returns true, at most until timeout, // or until the parent context of the test is cancelled. // -// If the condition takes longer than waitFor to complete, [Eventually] fails +// If the condition takes longer than the timeout to complete, [Eventually] fails // but waits for the current condition execution to finish before returning. // // For long-running conditions to be interrupted early, check [testing.T.Context] @@ -53,31 +53,72 @@ func Condition(t T, comp Comparison, msgAndArgs ...any) bool { // // assertions.Eventually(t, func() bool { return true }, time.Second, 10*time.Millisecond) // +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails when an error has always been returned up to timeout (equivalent semantics to func() bool returns false), +// expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// The semantics of the three available async assertions read as follows. +// +// - [Eventually] (func() bool) : "eventually returns true" +// +// - [Never] (func() bool) : "never returns true" +// +// - [Consistently] (func() bool): "always returns true" +// +// - [Eventually] (func(ctx) error) : "eventually returns nil" +// +// - [Never] (func(ctx) error) : not supported, use [Consistently] instead (avoids confusion with double negation) +// +// - [Consistently] (func(ctx) error): "always returns nil" +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// The condition function is always executed serially by a single goroutine. It is always executed at least once. +// +// It may thus write to variables outside its scope without triggering race conditions. // // A blocking condition will cause [Eventually] to hang until it returns. // +// Notice that time ticks may be skipped if the condition takes longer than the tick interval. +// +// # Attention point +// +// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +// counter-intuitive results, such as ticks or timeouts not firing in time as expected. +// +// To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << +// timeout). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond -func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { // Domain: condition if h, ok := t.(H); ok { h.Helper() } - return eventually(t, condition, waitFor, tick, msgAndArgs...) + return eventually(t, condition, timeout, tick, msgAndArgs...) } -// Never asserts that the given condition is never satisfied within waitFor time, +// Never asserts that the given condition is never satisfied until timeout, // periodically checking the target function at each tick. // -// [Never] is the opposite of [Eventually]. It succeeds if the waitFor timeout -// is reached without the condition ever returning true. +// [Never] is the opposite of [Eventually] ("at least once"). +// It succeeds if the timeout is reached without the condition ever returning true. // // If the parent context is cancelled before the timeout, [Never] fails. // @@ -85,27 +126,91 @@ func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur // // assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) // +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// Use [Consistently] instead if you want to use a condition returning an error. +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// See [Eventually]. +// +// # Attention point // -// A blocking condition will cause [Never] to hang until it returns. +// See [Eventually]. // // # 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, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + // Domain: condition + if h, ok := t.(H); ok { + h.Helper() + } + + return never(t, condition, timeout, tick, msgAndArgs...) +} + +// Consistently asserts that the given condition is always satisfied until timeout, +// periodically checking the target function at each tick. +// +// [Consistently] ("always") imposes a stronger constraint than [Eventually] ("at least once"): +// it checks at every tick that every occurrence of the condition is satisfied, whereas +// [Eventually] succeeds on the first occurrence of a successful condition. +// +// # Usage +// +// assertions.Consistently(t, func() bool { return true }, time.Second, 10*time.Millisecond) +// +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// The semantics of the assertion are "always returns true". +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails as soon as an error is returned before timeout expressing "always returns no error (nil)" +// +// This is consistent with [Eventually] expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// # Concurrency +// +// See [Eventually]. +// +// # Attention point +// +// See [Eventually]. +// +// # Examples +// +// success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond +// failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond +func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { // Domain: condition if h, ok := t.(H); ok { h.Helper() } - return never(t, condition, waitFor, tick, msgAndArgs...) + return consistently(t, condition, timeout, tick, msgAndArgs...) } -// EventuallyWith asserts that the given condition will be met in waitFor time, +// EventuallyWith asserts that the given condition will be met before the timeout, // periodically checking the target function at each tick. // // In contrast to [Eventually], the condition function is supplied with a [CollectT] @@ -114,10 +219,10 @@ func Never(t T, condition func() bool, waitFor time.Duration, tick time.Duration // The condition is considered "met" if no errors are raised in a tick. // The supplied [CollectT] collects all errors from one tick. // -// If the condition is not met before waitFor, the collected errors from the +// If the condition is not met before the timeout, the collected errors from the // last tick are copied to t. // -// Calling [CollectT.FailNow] cancels the condition immediately and fails the assertion. +// Calling [CollectT.FailNow] cancels the condition immediately and causes the assertion to fail. // // # Usage // @@ -145,16 +250,16 @@ func Never(t T, condition func() bool, waitFor time.Duration, tick time.Duration // // success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond // failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond -func EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { // Domain: condition if h, ok := t.(H); ok { h.Helper() } - return eventuallyWithT(t, condition, waitFor, tick, msgAndArgs...) + return eventuallyWithT(t, condition, timeout, tick, msgAndArgs...) } -func eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } @@ -164,10 +269,10 @@ func eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur failMessage: "condition never satisfied", }) - return p.pollCondition(t, condition, waitFor, tick, msgAndArgs...) + return p.pollCondition(t, makeCondition(condition, false), timeout, tick, msgAndArgs...) } -func never(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } @@ -177,26 +282,40 @@ func never(t T, condition func() bool, waitFor time.Duration, tick time.Duration failMessage: "condition satisfied", }) - return p.pollCondition(t, condition, waitFor, tick, msgAndArgs...) + return p.pollCondition(t, makeCondition(condition, true), timeout, tick, msgAndArgs...) } -func eventuallyWithT(t T, collectCondition func(collector *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) bool { +func consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + + p := newConditionPoller(pollOptions{ + mode: pollUntilTimeout, + failMessage: "condition failed once", + }) + + return p.pollCondition(t, makeCondition(condition, false), timeout, tick, msgAndArgs...) +} + +func eventuallyWithT[C CollectibleConditioner](t T, collectCondition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } var lastCollectedErrors []error var cancelFunc func() // will be set by pollCondition via onSetup + fn := makeCollectibleCondition(collectCondition) - condition := func() bool { + condition := func(ctx context.Context) error { collector := new(CollectT).withCancelFunc(cancelFunc) - collectCondition(collector) + fn(ctx, collector) if collector.failed() { lastCollectedErrors = collector.collected() - return false + return collector.last() } - return true + return nil } copyCollected := func(tt T) { @@ -212,7 +331,70 @@ func eventuallyWithT(t T, collectCondition func(collector *CollectT), waitFor ti onSetup: func(cancel func()) { cancelFunc = cancel }, }) - return p.pollCondition(t, condition, waitFor, tick, msgAndArgs...) + return p.pollCondition(t, condition, timeout, tick, msgAndArgs...) +} + +func makeCondition[C Conditioner](condition C, reverse bool) func(context.Context) error { + fn := any(condition) + + switch typed := fn.(type) { + case func() bool: + if !reverse { + return func(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if res := typed(); !res { + return errors.New("condition returned false") + } + + return nil + } + } + } + + // inverse bool <-> error logic for Never + return func(ctx context.Context) error { + select { + case <-ctx.Done(): + return nil + default: + if res := typed(); res { + return errors.New("condition returned true") + } + + return nil + } + } + case func(context.Context) error: + // 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 + 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) { + case func(*CollectT): + return func(ctx context.Context, collector *CollectT) { + select { + case <-ctx.Done(): + collector.Errorf("%v", ctx.Err()) + default: + typed(collector) + } + } + case func(context.Context, *CollectT): + return typed + default: // unreachable + panic(fmt.Errorf("unsupported CollectibleConditioner type. Mismatch with type constraint: %T", condition)) + } } type conditionPoller struct { @@ -220,17 +402,25 @@ type conditionPoller struct { ticker *time.Ticker reported atomic.Bool - conditionChan chan func() bool + conditionChan chan func(context.Context) error doneChan chan struct{} } +func newConditionPoller(o pollOptions) *conditionPoller { + return &conditionPoller{ + pollOptions: o, + conditionChan: make(chan func(context.Context) error, 1), + doneChan: make(chan struct{}), + } +} + // pollMode determines how the condition polling should behave. type pollMode int const ( // pollUntilTrue succeeds when condition returns true (for Eventually). pollUntilTrue pollMode = iota - // pollUntilTimeout succeeds when timeout is reached without condition being true (for Never). + // pollUntilTimeout succeeds when timeout is reached without condition being true (for Never/Consistently). pollUntilTimeout ) @@ -242,24 +432,16 @@ type pollOptions struct { onSetup func(cancel func()) // called after context setup to expose cancel function } -func newConditionPoller(o pollOptions) *conditionPoller { - return &conditionPoller{ - pollOptions: o, - conditionChan: make(chan func() bool, 1), - doneChan: make(chan struct{}), - } -} - // pollCondition is the common implementation for eventually, never, and eventuallyWithT. // // It polls a condition function at regular intervals until success or timeout. -func (p *conditionPoller) pollCondition(t T, condition func() bool, waitFor, tick time.Duration, msgAndArgs ...any) bool { +func (p *conditionPoller) pollCondition(t T, condition func(context.Context) error, timeout, tick time.Duration, msgAndArgs ...any) bool { if h, ok := t.(H); ok { h.Helper() } parentCtx := p.parentContextFromT(t) - ctx, cancel := p.cancellableContext(parentCtx, waitFor) + ctx, cancel := p.cancellableContext(parentCtx, timeout) defer cancel() failFunc := p.failFunc(t, msgAndArgs...) @@ -302,7 +484,7 @@ func (p *conditionPoller) failFunc(t T, msgAndArgs ...any) func(string) { } } -func (p *conditionPoller) pollAtTickFunc(parentCtx, ctx context.Context, condition func() bool, failFunc func(string), wg *sync.WaitGroup) func() { +func (p *conditionPoller) pollAtTickFunc(parentCtx, ctx context.Context, condition func(context.Context) error, failFunc func(string), wg *sync.WaitGroup) func() { if p.mode == pollUntilTimeout { // For Never: check parent context separately return func() { @@ -364,7 +546,7 @@ func (p *conditionPoller) pollAtTickFunc(parentCtx, ctx context.Context, conditi func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failFunc func(string), wg *sync.WaitGroup) func() { if p.mode == pollUntilTimeout { - // For Never + // For Never and Consistently return func() { defer wg.Done() @@ -376,8 +558,8 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF case <-ctx.Done(): return // timeout = success case fn := <-p.conditionChan: - if fn() { - close(p.doneChan) // condition true = failure for Never + if err := fn(ctx); err != nil { + close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently return } } @@ -395,8 +577,8 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF failFunc(ctx.Err().Error()) return case fn := <-p.conditionChan: - if fn() { - close(p.doneChan) // condition true = success + if err := fn(ctx); err == nil { + close(p.doneChan) // (condition true <=> err == nil) = success for Eventually return } } @@ -464,15 +646,15 @@ func (p *conditionPoller) parentContextFromT(t T) context.Context { return parentCtx } -func (p *conditionPoller) cancellableContext(parentCtx context.Context, waitFor time.Duration) (context.Context, func()) { +func (p *conditionPoller) cancellableContext(parentCtx context.Context, timeout time.Duration) (context.Context, func()) { // For pollUntilTimeout (Never), we detach from parent cancellation // so that timeout reaching is a success, not a failure. var ctx context.Context var cancel context.CancelFunc if p.mode == pollUntilTimeout { - ctx, cancel = context.WithTimeout(context.WithoutCancel(parentCtx), waitFor) + ctx, cancel = context.WithTimeout(context.WithoutCancel(parentCtx), timeout) } else { - ctx, cancel = context.WithTimeout(parentCtx, waitFor) + ctx, cancel = context.WithTimeout(parentCtx, timeout) } return ctx, cancel @@ -525,6 +707,14 @@ func (c *CollectT) collected() []error { return c.errors } +func (c *CollectT) last() error { + if len(c.errors) == 0 { + return nil + } + + return c.errors[len(c.errors)-1] +} + func (c *CollectT) withCancelFunc(cancel func()) *CollectT { c.cancelContext = cancel diff --git a/internal/assertions/condition_test.go b/internal/assertions/condition_test.go index 711499811..982a829de 100644 --- a/internal/assertions/condition_test.go +++ b/internal/assertions/condition_test.go @@ -5,6 +5,7 @@ package assertions import ( "context" + "errors" "iter" "slices" "sort" @@ -74,6 +75,57 @@ func TestConditionEventually(t *testing.T) { }) } +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). @@ -261,7 +313,7 @@ func TestConditionEventuallyWith(t *testing.T) { } const expectedErrors = 4 - if len(mock.errors) != expectedErrors { + 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)) } @@ -380,82 +432,180 @@ func TestConditionEventuallyWith(t *testing.T) { }) } -func TestConditionNever(t *testing.T) { +func TestConditionPollUntilTimeout(t *testing.T) { t.Parallel() - t.Run("should never be true", func(t *testing.T) { + 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() bool { - return false + condition := func(_ context.Context) error { + return nil // no error = condition not triggered } - if !Never(mock, condition, testTimeout, testTick) { - t.Error("expected Never to return true") + if !Consistently(mock, condition, testTimeout, testTick) { + t.Error("expected Consistently to return true when condition never returns an error") } }) - t.Run("should never be true, on timeout", func(t *testing.T) { + t.Run("should fail when condition returns an error", func(t *testing.T) { t.Parallel() mock := new(errorsCapturingT) - condition := func() bool { - time.Sleep(2 * testTick) - // eventually returns true, after timeout - return true + condition := func(_ context.Context) error { + return errors.New("something went wrong") } - if !Never(mock, condition, testTick, 1*time.Millisecond) { - t.Error("expected Never to return true on timeout") + if Consistently(mock, condition, testTimeout, testTick) { + t.Error("expected Consistently to return false when condition returns an error") } }) - t.Run("should never be true fails", func(t *testing.T) { - // checks Never with a condition that returns true on second call. + t.Run("should fail when error is returned on second call", func(t *testing.T) { t.Parallel() mock := new(errorsCapturingT) - // A list of values returned by condition. - // Channel protects against concurrent access. - returns := make(chan bool, 2) - returns <- false - returns <- true + returns := make(chan error, 2) + returns <- nil + returns <- errors.New("something went wrong") defer close(returns) - // Will return true on second call. - condition := func() bool { + condition := func(_ context.Context) error { return <-returns } - if Never(mock, condition, testTimeout, testTick) { - t.Error("expected Never to return false") + if Consistently(mock, condition, testTimeout, testTick) { + t.Error("expected Consistently to return false") } }) +} + +func TestConditionEventuallyWithContext(t *testing.T) { + t.Parallel() - t.Run("should never be true fails, with ticker never triggered", func(t *testing.T) { + t.Run("should complete with true using context variant", 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. - condition := func() bool { return true } - if Never(mock, condition, testTimeout, time.Second) { - t.Error("expected Never to return false") + 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 never be true fails, with parent test failing", func(t *testing.T) { + t.Run("should complete with false using context variant", 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, which results in Never to fail - return false + mock := new(errorsCapturingT) + condition := func(_ context.Context, collect *CollectT) { + Fail(collect, "condition fixed failure") } - if Never(mock, condition, testTimeout, time.Second) { - t.Error("expected Never to return false when parent test fails") + + 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") } }) } @@ -475,3 +625,29 @@ func conditionFailCases() iter.Seq[failCase] { }, }) } + +// pollUntilTimeoutAssertion is the common signature for Never and Consistently, +// both of which poll until timeout using func() bool conditions. +type pollUntilTimeoutAssertion func(T, func() bool, time.Duration, time.Duration, ...any) bool + +// pollUntilTimeoutCase parameterizes the shared tests for Never and Consistently. +type pollUntilTimeoutCase struct { + name string + assertion pollUntilTimeoutAssertion + goodValue bool // the value the condition returns when "holding": false for Never, true for Consistently +} + +func pollUntilTimeoutCases() iter.Seq[pollUntilTimeoutCase] { + return slices.Values([]pollUntilTimeoutCase{ + { + name: "Never", + assertion: Never, + goodValue: false, // Never succeeds when the condition always returns false ("never true") + }, + { + name: "Consistently", + assertion: Consistently[func() bool], + goodValue: true, // Consistently succeeds when the condition always returns true ("always true") + }, + }) +} diff --git a/internal/assertions/enable/yaml/enable_yaml.go b/internal/assertions/enable/yaml/enable_yaml.go index c411f23e6..10b764be7 100644 --- a/internal/assertions/enable/yaml/enable_yaml.go +++ b/internal/assertions/enable/yaml/enable_yaml.go @@ -24,7 +24,7 @@ func EnableYAMLWithMarshal(marshaler func(any) ([]byte, error)) { enableYAMLMarshal = marshaler } -// Unmarshal is a wrapper to some exernal library to unmarshal YAML documents. +// Unmarshal is a wrapper to some external library to unmarshal YAML documents. func Unmarshal(in []byte, out any) error { if enableYAMLUnmarshal == nil { // fail early and loud @@ -43,7 +43,7 @@ import ( return enableYAMLUnmarshal(in, out) } -// Marshal is a wrapper to some exernal library to marshal YAML documents. +// Marshal is a wrapper to some external library to marshal YAML documents. func Marshal(in any) ([]byte, error) { if enableYAMLMarshal == nil { // fail early and loud diff --git a/internal/assertions/error.go b/internal/assertions/error.go index 5a25835e8..f28839149 100644 --- a/internal/assertions/error.go +++ b/internal/assertions/error.go @@ -296,10 +296,11 @@ func buildErrorChainString(err error, withType bool) string { if i != 0 { chain.WriteString("\n\t") } - chain.WriteString(fmt.Sprintf("%q", errs[i].Error())) + fmt.Fprintf(&chain, "%q", errs[i].Error()) if withType { - chain.WriteString(fmt.Sprintf(" (%T)", errs[i])) + fmt.Fprintf(&chain, " (%T)", errs[i]) } } + return chain.String() } diff --git a/internal/assertions/format.go b/internal/assertions/format.go index 72aaeb0d1..b523ec0a6 100644 --- a/internal/assertions/format.go +++ b/internal/assertions/format.go @@ -43,7 +43,7 @@ func indentMessageLines(message string, longestLabelLen int) string { if !firstLine { fmt.Fprint(outBuf, "\n\t"+strings.Repeat(" ", longestLabelLen+1)+"\t") } - fmt.Fprint(outBuf, scanner.Text()) + fmt.Fprint(outBuf, scanner.Text()) //nolint:gosec // gosec false positive: G705: XSS via taint analysis } return outBuf.String() diff --git a/internal/assertions/generics.go b/internal/assertions/generics.go index e2e3a1168..5d8dbb3ee 100644 --- a/internal/assertions/generics.go +++ b/internal/assertions/generics.go @@ -5,6 +5,7 @@ package assertions import ( "cmp" + "context" "regexp" "time" ) @@ -62,4 +63,18 @@ type ( RegExp interface { Text | *regexp.Regexp } + + // Conditioner is a function used in asynchronous condition assertions. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + Conditioner interface { + func() bool | func(context.Context) error + } + + // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + CollectibleConditioner interface { + func(*CollectT) | func(context.Context, *CollectT) + } ) diff --git a/internal/assertions/http_test.go b/internal/assertions/http_test.go index 569659916..1c8d146b9 100644 --- a/internal/assertions/http_test.go +++ b/internal/assertions/http_test.go @@ -253,7 +253,7 @@ func httpFailCases() iter.Seq[failCase] { func httpHelloName(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") - _, _ = fmt.Fprintf(w, "Hello, %s!", name) + _, _ = fmt.Fprintf(w, "Hello, %s!", name) //nolint:gosec // gosec false positive: G705: XSS via taint analysis } func httpOK(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/assertions/ifaces.go b/internal/assertions/ifaces.go index 473ba3d0c..2a3ef5b6a 100644 --- a/internal/assertions/ifaces.go +++ b/internal/assertions/ifaces.go @@ -32,9 +32,6 @@ type ( // ErrorAssertionFunc is a common function prototype when validating an error value. Can be useful // for table driven tests. ErrorAssertionFunc func(T, error, ...any) bool - - // Comparison is a custom function that returns true on success and false on failure. - Comparison func() (success bool) ) type failNower interface { diff --git a/internal/spew/README.md b/internal/spew/README.md index 60a2214a5..1eb5e9071 100644 --- a/internal/spew/README.md +++ b/internal/spew/README.md @@ -58,10 +58,10 @@ This internalized copy has been modernized from the original go-spew codebase wi The internalized copy maintains API compatibility with the original while incorporating targeted improvements: - **Deterministic map sorting:** The `SpewKeys` configuration option enables sorted map key output for consistent diffs (relevant for testify's assertion output) - - [x] Additional optimizations for deterministic diff generation (stretchr/testify#1822) -- **time.Time rendering:** Enhanced handling of `time.Time` values in nested structures (applied from stretchr/testify#1829) + - [x] Additional optimizations for deterministic diff generation (`stretchr/testify#1822`) +- **time.Time rendering:** Enhanced handling of `time.Time` values in nested structures (applied from `stretchr/testify#1829`) - **panic/hang** Reinforced input checking to avoid edge cases - - [x] Proper fix for panic on unexported struct keys in maps (stretchr/testify#1816) + - [x] Proper fix for panic on unexported struct keys in maps (`stretchr/testify#1816`) - [x] Fix for circular map references (runtime stack overflow) ## Future Enhancements diff --git a/internal/spew/common_test.go b/internal/spew/common_test.go index 19dbd4c85..917a4c3f5 100644 --- a/internal/spew/common_test.go +++ b/internal/spew/common_test.go @@ -319,20 +319,22 @@ func (e customError) Error() string { return fmt.Sprintf("error: %d", int(e)) } -// stringizeWants verts a slice of wanted test output into a format suitable +// stringizeWants converts a slice of wanted test output into a format suitable // for a test error message. func stringizeWants(wants []string) string { - s := "" - var sSb97 strings.Builder + var b strings.Builder + for i, want := range wants { if i > 0 { - sSb97.WriteString(fmt.Sprintf("want%d: %s", i+1, want)) - } else { - sSb97.WriteString("want: " + want) + fmt.Fprintf(&b, "want%d: %s", i+1, want) + + continue } + + b.WriteString("want: " + want) } - s += sSb97.String() - return s + + return b.String() } // testFailed returns whether or not a test failed by checking if the result diff --git a/internal/spew/spew_test.go b/internal/spew/spew_test.go index 2b8ba318a..59d6452d7 100644 --- a/internal/spew/spew_test.go +++ b/internal/spew/spew_test.go @@ -382,5 +382,5 @@ func redirStdout(f func()) ([]byte, error) { os.Stdout = origStdout tempFile.Close() - return os.ReadFile(fileName) + return os.ReadFile(fileName) //nolint:gosec // false positive: G703: Path traversal via taint analysis } diff --git a/internal/testintegration/README.md b/internal/testintegration/README.md index 176a154fb..884abd107 100644 --- a/internal/testintegration/README.md +++ b/internal/testintegration/README.md @@ -42,9 +42,9 @@ internal/testintegration/ ## Dependencies -- **pgregory.net/rapid** - Property-based testing library with fuzzing capabilities -- **go.yaml.in/yaml/v3** - YAML parsing (for YAML assertion integration tests) -- **github.com/go-openapi/testify/enable/colors/v2** - Colorized output activation +- **`pgregory.net/rapid`** - Property-based testing library with fuzzing capabilities +- **`go.yaml.in/yaml/v3`** - YAML parsing (for YAML assertion integration tests) +- **`github.com/go-openapi/testify/enable/colors/v2`** - Colorized output activation ## Test Packages diff --git a/require/require_adhoc_example_8_test.go b/require/require_adhoc_example_8_test.go new file mode 100644 index 000000000..8f25da9de --- /dev/null +++ b/require/require_adhoc_example_8_test.go @@ -0,0 +1,115 @@ +// 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" +) + +// ExampleEventually_asyncReady demonstrates polling a condition that becomes true +// after a few attempts, simulating an asynchronous operation completing. +func ExampleEventually_asyncReady() { + t := new(testing.T) // normally provided by test + + // Simulate an async operation that completes after a short delay. + var ready atomic.Bool + go func() { + time.Sleep(30 * time.Millisecond) + ready.Store(true) + }() + + require.Eventually(t, ready.Load, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually ready: %t", !t.Failed()) + + // Output: eventually ready: true +} + +// ExampleEventually_healthCheck demonstrates [Eventually] with a +// func(context.Context) error condition, polling until the operation +// succeeds (returns nil). +func ExampleEventually_healthCheck() { + t := new(testing.T) // normally provided by test + + // Simulate a service that becomes healthy after a few attempts. + 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, healthCheck, 200*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("eventually healthy: %t", !t.Failed()) + + // Output: eventually healthy: true +} + +// ExampleNever_noSpuriousEvents demonstrates asserting that a condition never becomes true +// during the observation period. +func ExampleNever_noSpuriousEvents() { + t := new(testing.T) // normally provided by test + + // A channel that should remain empty during the test. + events := make(chan struct{}, 1) + + require.Never(t, func() bool { + select { + case <-events: + return true // event received = condition becomes true = Never fails + default: + return false + } + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("never received: %t", !t.Failed()) + + // Output: never received: true +} + +// ExampleConsistently_invariant demonstrates asserting that a condition remains true +// throughout the entire observation period. +func ExampleConsistently_invariant() { + t := new(testing.T) // normally provided by test + + // A counter that stays within bounds during the test. + var counter atomic.Int32 + counter.Store(5) + + require.Consistently(t, func() bool { + return counter.Load() < 10 + }, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently under limit: %t", !t.Failed()) + + // Output: consistently under limit: true +} + +// ExampleConsistently_alwaysHealthy demonstrates [Consistently] with a +// func(context.Context) error condition, asserting that the operation +// always succeeds (returns nil) throughout the observation period. +func ExampleConsistently_alwaysHealthy() { + t := new(testing.T) // normally provided by test + + // Simulate a service that stays healthy. + healthCheck := func(_ context.Context) error { + return nil // always healthy + } + + require.Consistently(t, healthCheck, 100*time.Millisecond, 10*time.Millisecond) + + fmt.Printf("consistently healthy: %t", !t.Failed()) + + // Output: consistently healthy: true +} diff --git a/require/require_assertions.go b/require/require_assertions.go index c0698fd6c..edbc9a05b 100644 --- a/require/require_assertions.go +++ b/require/require_assertions.go @@ -15,7 +15,7 @@ import ( "github.com/go-openapi/testify/v2/internal/assertions" ) -// Condition uses a [Comparison] to assert a complex condition. +// Condition uses a comparison function to assert a complex condition. // // # Usage // @@ -27,7 +27,7 @@ import ( // failure: func() bool { return false } // // Upon failure, the test [T] is marked as failed and stops execution. -func Condition(t T, comp Comparison, msgAndArgs ...any) { +func Condition(t T, comp func() bool, msgAndArgs ...any) { if h, ok := t.(H); ok { h.Helper() } @@ -38,6 +38,63 @@ func Condition(t T, comp Comparison, msgAndArgs ...any) { t.FailNow() } +// Consistently asserts that the given condition is always satisfied until timeout, +// periodically checking the target function at each tick. +// +// [Consistently] ("always") imposes a stronger constraint than [Eventually] ("at least once"): +// it checks at every tick that every occurrence of the condition is satisfied, whereas +// [Eventually] succeeds on the first occurrence of a successful condition. +// +// # Usage +// +// assertions.Consistently(t, func() bool { return true }, time.Second, 10*time.Millisecond) +// +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// The semantics of the assertion are "always returns true". +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails as soon as an error is returned before timeout expressing "always returns no error (nil)" +// +// This is consistent with [Eventually] expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// # Concurrency +// +// See [Eventually]. +// +// # Attention point +// +// See [Eventually]. +// +// # Examples +// +// success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond +// failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond +// +// Upon failure, the test [T] is marked as failed and stops execution. +func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.Consistently[C](t, condition, timeout, tick, msgAndArgs...) { + return + } + + t.FailNow() +} + // Contains asserts that the specified string, list(array, slice...) or map contains the // specified substring or element. // @@ -438,13 +495,13 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) { t.FailNow() } -// Eventually asserts that the given condition will be met in waitFor time, +// Eventually asserts that the given condition will be met before timeout, // periodically checking the target function on each tick. // -// [Eventually] waits until the condition returns true, for at most waitFor, +// [Eventually] waits until the condition returns true, at most until timeout, // or until the parent context of the test is cancelled. // -// If the condition takes longer than waitFor to complete, [Eventually] fails +// If the condition takes longer than the timeout to complete, [Eventually] fails // but waits for the current condition execution to finish before returning. // // For long-running conditions to be interrupted early, check [testing.T.Context] @@ -454,31 +511,72 @@ func ErrorIs(t T, err error, target error, msgAndArgs ...any) { // // assertions.Eventually(t, func() bool { return true }, time.Second, 10*time.Millisecond) // +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// To build more complex cases, a condition may also be defined as: +// +// func(context.Context) error +// +// It fails when an error has always been returned up to timeout (equivalent semantics to func() bool returns false), +// expressing "eventually returns no error (nil)". +// +// It will be executed with the context of the assertion, which inherits the [testing.T.Context] and +// is cancelled on timeout. +// +// The semantics of the three available async assertions read as follows. +// +// - [Eventually] (func() bool) : "eventually returns true" +// +// - [Never] (func() bool) : "never returns true" +// +// - [Consistently] (func() bool): "always returns true" +// +// - [Eventually] (func(ctx) error) : "eventually returns nil" +// +// - [Never] (func(ctx) error) : not supported, use [Consistently] instead (avoids confusion with double negation) +// +// - [Consistently] (func(ctx) error): "always returns nil" +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// The condition function is always executed serially by a single goroutine. It is always executed at least once. +// +// It may thus write to variables outside its scope without triggering race conditions. // // A blocking condition will cause [Eventually] to hang until it returns. // +// Notice that time ticks may be skipped if the condition takes longer than the tick interval. +// +// # Attention point +// +// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +// counter-intuitive results, such as ticks or timeouts not firing in time as expected. +// +// To avoid flaky tests, always make sure that ticks and timeouts differ by at least an order of magnitude (tick << +// timeout). +// // # Examples // // success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond // failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and stops execution. -func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { +func Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Eventually(t, condition, waitFor, tick, msgAndArgs...) { + if assertions.Eventually[C](t, condition, timeout, tick, msgAndArgs...) { return } t.FailNow() } -// EventuallyWith asserts that the given condition will be met in waitFor time, +// EventuallyWith asserts that the given condition will be met before the timeout, // periodically checking the target function at each tick. // // In contrast to [Eventually], the condition function is supplied with a [CollectT] @@ -487,10 +585,10 @@ func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur // The condition is considered "met" if no errors are raised in a tick. // The supplied [CollectT] collects all errors from one tick. // -// If the condition is not met before waitFor, the collected errors from the +// If the condition is not met before the timeout, the collected errors from the // last tick are copied to t. // -// Calling [CollectT.FailNow] cancels the condition immediately and fails the assertion. +// Calling [CollectT.FailNow] cancels the condition immediately and causes the assertion to fail. // // # Usage // @@ -520,11 +618,11 @@ func Eventually(t T, condition func() bool, waitFor time.Duration, tick time.Dur // failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond // // Upon failure, the test [T] is marked as failed and stops execution. -func EventuallyWith(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { +func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.EventuallyWith(t, condition, waitFor, tick, msgAndArgs...) { + if assertions.EventuallyWith[C](t, condition, timeout, tick, msgAndArgs...) { return } @@ -1988,11 +2086,11 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an t.FailNow() } -// Never asserts that the given condition is never satisfied within waitFor time, +// Never asserts that the given condition is never satisfied until timeout, // periodically checking the target function at each tick. // -// [Never] is the opposite of [Eventually]. It succeeds if the waitFor timeout -// is reached without the condition ever returning true. +// [Never] is the opposite of [Eventually] ("at least once"). +// It succeeds if the timeout is reached without the condition ever returning true. // // If the parent context is cancelled before the timeout, [Never] fails. // @@ -2000,12 +2098,23 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // // assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) // +// See also [Eventually] for details about using context and concurrency. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// Use [Consistently] instead if you want to use a condition returning an error. +// // # Concurrency // -// The condition function is never executed in parallel: only one goroutine executes it. -// It may write to variables outside its scope without triggering race conditions. +// See [Eventually]. +// +// # Attention point // -// A blocking condition will cause [Never] to hang until it returns. +// See [Eventually]. // // # Examples // @@ -2013,11 +2122,11 @@ func NegativeT[SignedNumber SignedNumeric](t T, e SignedNumber, msgAndArgs ...an // 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, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { +func Never(t T, condition func() bool, timeout time.Duration, tick time.Duration, msgAndArgs ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Never(t, condition, waitFor, tick, msgAndArgs...) { + if assertions.Never(t, condition, timeout, tick, msgAndArgs...) { return } diff --git a/require/require_assertions_test.go b/require/require_assertions_test.go index 01ade79df..0217339a7 100644 --- a/require/require_assertions_test.go +++ b/require/require_assertions_test.go @@ -40,6 +40,29 @@ func TestCondition(t *testing.T) { }) } +func TestConsistently(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + Consistently(mock, func() bool { return true }, 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) + Consistently(mock, func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) + // require functions don't return a value + if !mock.failed { + t.Error("Consistently should call FailNow()") + } + }) +} + func TestContains(t *testing.T) { t.Parallel() diff --git a/require/require_examples_test.go b/require/require_examples_test.go index cae077385..6b778703b 100644 --- a/require/require_examples_test.go +++ b/require/require_examples_test.go @@ -31,6 +31,16 @@ func ExampleCondition() { // Output: passed } +func ExampleConsistently() { + t := new(testing.T) // should come from testing, e.g. func TestConsistently(t *testing.T) + require.Consistently(t, func() bool { + return true + }, 100*time.Millisecond, 20*time.Millisecond) + fmt.Println("passed") + + // Output: passed +} + func ExampleContains() { t := new(testing.T) // should come from testing, e.g. func TestContains(t *testing.T) require.Contains(t, []string{"A", "B"}, "A") diff --git a/require/require_format.go b/require/require_format.go index 1a3bb53ce..865c93b66 100644 --- a/require/require_format.go +++ b/require/require_format.go @@ -18,7 +18,7 @@ import ( // Conditionf is the same as [Condition], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. -func Conditionf(t T, comp Comparison, msg string, args ...any) { +func Conditionf(t T, comp func() bool, msg string, args ...any) { if h, ok := t.(H); ok { h.Helper() } @@ -29,6 +29,20 @@ func Conditionf(t T, comp Comparison, msg string, args ...any) { t.FailNow() } +// Consistentlyf is the same as [Consistently], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func Consistentlyf[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.Consistently[C](t, condition, timeout, tick, forwardArgs(msg, args)) { + return + } + + t.FailNow() +} + // Containsf is the same as [Contains], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. @@ -242,11 +256,11 @@ func ErrorIsf(t T, err error, target error, msg string, args ...any) { // Eventuallyf is the same as [Eventually], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. -func Eventuallyf(t T, condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) { +func Eventuallyf[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Eventually(t, condition, waitFor, tick, forwardArgs(msg, args)) { + if assertions.Eventually[C](t, condition, timeout, tick, forwardArgs(msg, args)) { return } @@ -256,11 +270,11 @@ func Eventuallyf(t T, condition func() bool, waitFor time.Duration, tick time.Du // EventuallyWithf is the same as [EventuallyWith], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. -func EventuallyWithf(t T, condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) { +func EventuallyWithf[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.EventuallyWith(t, condition, waitFor, tick, forwardArgs(msg, args)) { + if assertions.EventuallyWith[C](t, condition, timeout, tick, forwardArgs(msg, args)) { return } @@ -1022,11 +1036,11 @@ func NegativeTf[SignedNumber SignedNumeric](t T, e SignedNumber, msg string, arg // Neverf is the same as [Never], but it accepts a format msg 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, waitFor time.Duration, tick time.Duration, msg string, args ...any) { +func Neverf(t T, condition func() bool, timeout time.Duration, tick time.Duration, msg string, args ...any) { if h, ok := t.(H); ok { h.Helper() } - if assertions.Never(t, condition, waitFor, tick, forwardArgs(msg, args)) { + if assertions.Never(t, condition, timeout, tick, forwardArgs(msg, args)) { return } diff --git a/require/require_format_test.go b/require/require_format_test.go index 7799de412..c1ab07271 100644 --- a/require/require_format_test.go +++ b/require/require_format_test.go @@ -40,6 +40,29 @@ func TestConditionf(t *testing.T) { }) } +func TestConsistentlyf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + Consistentlyf(mock, func() bool { return true }, 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) + Consistentlyf(mock, func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") + // require functions don't return a value + if !mock.failed { + t.Error("Consistentlyf should call FailNow()") + } + }) +} + func TestContainsf(t *testing.T) { t.Parallel() diff --git a/require/require_forward.go b/require/require_forward.go index 5a4f77bbc..a73d55afb 100644 --- a/require/require_forward.go +++ b/require/require_forward.go @@ -33,7 +33,7 @@ func New(t T) *Assertions { // Condition is the same as [Condition], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Condition(comp Comparison, msgAndArgs ...any) { +func (a *Assertions) Condition(comp func() bool, msgAndArgs ...any) { if h, ok := a.T.(H); ok { h.Helper() } @@ -47,7 +47,7 @@ func (a *Assertions) Condition(comp Comparison, msgAndArgs ...any) { // Conditionf is the same as [Assertions.Condition], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Conditionf(comp Comparison, msg string, args ...any) { +func (a *Assertions) Conditionf(comp func() bool, msg string, args ...any) { if h, ok := a.T.(H); ok { h.Helper() } @@ -422,62 +422,6 @@ func (a *Assertions) ErrorIsf(err error, target error, msg string, args ...any) a.T.FailNow() } -// Eventually is the same as [Eventually], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Eventually(condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.Eventually(a.T, condition, waitFor, tick, msgAndArgs...) { - return - } - - a.T.FailNow() -} - -// Eventuallyf is the same as [Assertions.Eventually], but it accepts a format msg string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) Eventuallyf(condition func() bool, waitFor time.Duration, tick time.Duration, msg string, args ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.Eventually(a.T, condition, waitFor, tick, forwardArgs(msg, args)) { - return - } - - a.T.FailNow() -} - -// EventuallyWith is the same as [EventuallyWith], as a method rather than a package-level function. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) EventuallyWith(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.EventuallyWith(a.T, condition, waitFor, tick, msgAndArgs...) { - return - } - - a.T.FailNow() -} - -// EventuallyWithf is the same as [Assertions.EventuallyWith], but it accepts a format msg string to format arguments like [fmt.Printf]. -// -// Upon failure, the test [T] is marked as failed and stops execution. -func (a *Assertions) EventuallyWithf(condition func(collect *CollectT), waitFor time.Duration, tick time.Duration, msg string, args ...any) { - if h, ok := a.T.(H); ok { - h.Helper() - } - if assertions.EventuallyWith(a.T, condition, waitFor, tick, forwardArgs(msg, args)) { - return - } - - a.T.FailNow() -} - // Exactly is the same as [Exactly], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and stops execution. @@ -1453,11 +1397,11 @@ func (a *Assertions) Negativef(e any, msg string, args ...any) { // 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, waitFor time.Duration, tick time.Duration, msgAndArgs ...any) { +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, waitFor, tick, msgAndArgs...) { + if assertions.Never(a.T, condition, timeout, tick, msgAndArgs...) { return } @@ -1467,11 +1411,11 @@ func (a *Assertions) Never(condition func() bool, waitFor time.Duration, tick ti // Neverf is the same as [Assertions.Never], but it accepts a format msg 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, waitFor time.Duration, tick time.Duration, msg string, args ...any) { +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, waitFor, tick, forwardArgs(msg, args)) { + if assertions.Never(a.T, condition, timeout, tick, forwardArgs(msg, args)) { return } diff --git a/require/require_forward_test.go b/require/require_forward_test.go index ce42f7f04..b535a1157 100644 --- a/require/require_forward_test.go +++ b/require/require_forward_test.go @@ -366,56 +366,6 @@ func TestAssertionsErrorIs(t *testing.T) { }) } -func TestAssertionsEventually(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Eventually(func() bool { return true }, 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.Eventually(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond) - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.Eventually should call FailNow()") - } - }) -} - -func TestAssertionsEventuallyWith(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.EventuallyWith(func(c *CollectT) { True(c, true) }, 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.EventuallyWith(func(c *CollectT) { False(c, true) }, 100*time.Millisecond, 20*time.Millisecond) - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.EventuallyWith should call FailNow()") - } - }) -} - func TestAssertionsExactly(t *testing.T) { t.Parallel() @@ -2408,56 +2358,6 @@ func TestAssertionsErrorIsf(t *testing.T) { }) } -func TestAssertionsEventuallyf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.Eventuallyf(func() bool { return true }, 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.Eventuallyf(func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond, "test message") - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.Eventuallyf should call FailNow()") - } - }) -} - -func TestAssertionsEventuallyWithf(t *testing.T) { - t.Parallel() - - t.Run("success", func(t *testing.T) { - t.Parallel() - - mock := new(mockFailNowT) - a := New(mock) - a.EventuallyWithf(func(c *CollectT) { True(c, true) }, 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.EventuallyWithf(func(c *CollectT) { False(c, true) }, 100*time.Millisecond, 20*time.Millisecond, "test message") - // require functions don't return a value - if !mock.failed { - t.Error("Assertions.EventuallyWithf should call FailNow()") - } - }) -} - func TestAssertionsExactlyf(t *testing.T) { t.Parallel() diff --git a/require/require_types.go b/require/require_types.go index fe025e629..b8dc1cf44 100644 --- a/require/require_types.go +++ b/require/require_types.go @@ -32,13 +32,20 @@ type ( // should not be used outside of that context. CollectT = assertions.CollectT - // Comparison is a custom function that returns true on success and false on failure. - Comparison = assertions.Comparison + // CollectibleConditioner is a function used in asynchronous condition assertions that use [CollectT]. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([EventuallyWith]). + CollectibleConditioner = assertions.CollectibleConditioner // ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful // for table driven tests. ComparisonAssertionFunc func(T, any, any, ...any) + // Conditioner is a function used in asynchronous condition assertions. + // + // This type constraint allows for "overloaded" versions of the condition assertions ([Eventually], [Consistently]). + Conditioner = assertions.Conditioner + // ErrorAssertionFunc is a common function prototype when validating an error value. Can be useful // for table driven tests. ErrorAssertionFunc func(T, error, ...any)