diff --git a/README.md b/README.md index 8f541b2c0..166fbb5aa 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,9 @@ See the license [NOTICE](./NOTICE), which recalls the licensing terms of all the distributed with this fork, including internalized libraries. We'd like to give credit and pay a big and loud thank you to the people who wrote these amazing pieces of software. -We maintain and continue improving their original work here. +We maintain their original work here and continue improving it. -* Mat Ryer, Tyler Bunnell and the stretchr/testify contributors - who made this new project possible +* Mat Ryer, Tyler Bunnell and the `stretchr/testify` contributors - who made this new project possible * thank you to the maintainers of that library, @ccoveille, @dolmen for their feedback and advice * Dave Collins who wrote the spew library * Patrick Mezard who wrote the difflib library - originally a go port from python's difflib diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index da520c3b0..28e4839e9 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -3417,7 +3417,7 @@ func YAMLEqT[EDoc, ADoc RText](t T, expected EDoc, actual ADoc, msgAndArgs ...an // // # Usage // -// actual := struct { +// expected := struct { // A int `yaml:"a"` // }{ // A: 10, diff --git a/codegen/internal/scanner/buildtags.go b/codegen/internal/scanner/buildtags.go index cf8572da4..8de503316 100644 --- a/codegen/internal/scanner/buildtags.go +++ b/codegen/internal/scanner/buildtags.go @@ -4,9 +4,17 @@ package scanner import ( + "errors" + "fmt" "go/ast" "go/build/constraint" + "go/parser" "go/token" + "os" + "path/filepath" + "slices" + "strconv" + "strings" "golang.org/x/tools/go/packages" ) @@ -54,3 +62,110 @@ func fileBuildConstraint(f *ast.File) string { return "" } + +// ErrGuardedFileDropped signals that a go-version-guarded source file never reached the scan. +var ErrGuardedFileDropped = errors.New("guarded source file missing from the scan") + +// goVersionGuard returns the minor version N of a plain "go1.N" build constraint. +// +// Only a constraint that is exactly one go-version term qualifies. A file selected on +// something else — an OS, an architecture, a negation — is legitimately absent on some +// machines, and must not be mistaken for a file the toolchain dropped. +func goVersionGuard(expr string) (int, bool) { + rest, ok := strings.CutPrefix(expr, "go1.") + if !ok { + return 0, false + } + + minor, err := strconv.Atoi(rest) + if err != nil { + return 0, false + } + + return minor, true +} + +// guardedFilesOnDisk maps every non-test Go file in dir carrying a plain "//go:build go1.N" +// line to that constraint. +// +// The scan reads the directory with go/parser rather than the typed package on purpose: a +// file guarded above the toolchain running the scan never reaches [packages.Package], so the +// typed view cannot report what it is missing. +func guardedFilesOnDisk(dir string) (map[string]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("can't read %q to look for build constraints: %w", dir, err) + } + + guarded := make(map[string]string) + fset := token.NewFileSet() + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue // the scan loads production files only (packages.Config.Tests is false) + } + + astFile, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.PackageClauseOnly|parser.ParseComments) + if err != nil { + continue // unparseable: the compiler reports it, we don't + } + + expr := fileBuildConstraint(astFile) + if _, ok := goVersionGuard(expr); ok { + guarded[name] = expr + } + } + + return guarded, nil +} + +// verifyGuardedFilesLoaded returns an error when a "//go:build go1.N" file sits in the scanned +// package directory but never made it into the typed load. +// +// That happens when the go command running the scan is older than the guard: it drops the file +// before the scanner sees it, and generating from that view leaves out every assertion the file +// declares, with nothing in the output to show for it. The toolchain that built this binary +// says nothing about the matter — [packages.Load] shells out to the go command on PATH. +func verifyGuardedFilesLoaded(pkg *packages.Package) error { + if pkg.Dir == "" { + return nil + } + + guarded, err := guardedFilesOnDisk(pkg.Dir) + if err != nil { + return err + } + + loaded := make(map[string]struct{}, len(pkg.GoFiles)) + for _, path := range pkg.GoFiles { + loaded[filepath.Base(path)] = struct{}{} + } + + dropped := make([]string, 0, len(guarded)) + highest := 0 + + for name, expr := range guarded { + if _, ok := loaded[name]; ok { + continue + } + + dropped = append(dropped, fmt.Sprintf("%s (//go:build %s)", name, expr)) + if minor, ok := goVersionGuard(expr); ok && minor > highest { + highest = minor + } + } + + if len(dropped) == 0 { + return nil + } + + slices.Sort(dropped) + + return fmt.Errorf( + "%w: %s. The go command running the scan is older than these guards, so the assertions they declare "+ + "would be left out of the generated packages and documentation. Rerun with GOTOOLCHAIN=go1.%d.0 "+ + "(see docs/doc-site/project/maintainers/CODEGEN.md, \"Regenerating\")", + ErrGuardedFileDropped, strings.Join(dropped, ", "), highest, + ) +} diff --git a/codegen/internal/scanner/scanner.go b/codegen/internal/scanner/scanner.go index 932635aee..8f096e253 100644 --- a/codegen/internal/scanner/scanner.go +++ b/codegen/internal/scanner/scanner.go @@ -72,6 +72,13 @@ func (s *Scanner) Scan() (*model.AssertionPackage, error) { // we consider only one package pkg := pkgs[0] + + // A file guarded above the toolchain running the load never reached pkg. Stop here rather + // than generate from a view that is quietly missing assertions. + if err := verifyGuardedFilesLoaded(pkg); err != nil { + return nil, err + } + s.syntaxPackage = pkg.Syntax s.typedPackage = pkg.Types if s.typedPackage == nil { diff --git a/codegen/internal/scanner/toolchain_invariant_test.go b/codegen/internal/scanner/toolchain_invariant_test.go index 245663384..23d79d996 100644 --- a/codegen/internal/scanner/toolchain_invariant_test.go +++ b/codegen/internal/scanner/toolchain_invariant_test.go @@ -4,115 +4,102 @@ package scanner import ( - "go/parser" - "go/token" + "errors" "os" "path/filepath" - "regexp" - "strconv" + "strings" "testing" -) - -// repoRootFromScanner is the path from this test package to the repository root. -const repoRootFromScanner = "../../.." -var goMinorRx = regexp.MustCompile(`go1\.(\d+)`) + "golang.org/x/tools/go/packages" +) -// TestToolchainFloorCoversGuards enforces the invariant that every //go:build go1.N guard -// used in internal/assertions is covered by the go.work toolchain floor. +// TestGuardedFilesOnDisk checks which files the textual scan reports as go-version-guarded. // -// codegen runs in workspace mode, where the go.work toolchain line selects the toolchain. -// A guard above that floor could be silently dropped (go/packages would not even load the -// file), producing incomplete generated output. Bumping the floor must therefore happen in -// lockstep with introducing a higher guard. -func TestToolchainFloorCoversGuards(t *testing.T) { - floor := workToolchainMinor(t, filepath.Join(repoRootFromScanner, "go.work")) - maxGuard := maxAssertionGuardMinor(t, filepath.Join(repoRootFromScanner, "internal", "assertions")) - - t.Logf("go.work toolchain floor: go1.%d, highest internal/assertions guard: go1.%d", floor, maxGuard) - - if maxGuard > floor { - t.Fatalf( - "internal/assertions uses //go:build go1.%d but the go.work toolchain floor is go1.%d; "+ - "bump the go.work toolchain line to at least go1.%d so codegen observes the guarded file", - maxGuard, floor, maxGuard, - ) +// Only a plain "go1.N" constraint counts. A file selected on an OS or an architecture is +// absent on some machines by design, and a test file never reaches the load in the first +// place: neither may be reported as dropped. +func TestGuardedFilesOnDisk(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } } -} -// workToolchainMinor returns the minor version of the go.work toolchain floor, falling back -// to the workspace `go` directive when no explicit toolchain line is present. -func workToolchainMinor(t *testing.T, path string) int { - t.Helper() + write("plain.go", "package assertions\n") + write("guarded.go", "//go:build go1.99\n\npackage assertions\n") + write("guarded_test.go", "//go:build go1.99\n\npackage assertions\n") + write("platform.go", "//go:build !windows\n\npackage assertions\n") + write("combined.go", "//go:build go1.99 && !windows\n\npackage assertions\n") + write("broken.go", "//go:build go1.99\n\npackage ***\n") + write("notgo.txt", "//go:build go1.99\n") - data, err := os.ReadFile(path) // sometimes a false positive: nolint:gosec // test reads a fixed in-repo file + guarded, err := guardedFilesOnDisk(dir) if err != nil { - t.Fatalf("read %s: %v", path, err) + t.Fatalf("guardedFilesOnDisk: %v", err) } - // Prefer the toolchain directive (e.g. "toolchain go1.26.0"); otherwise the go directive - // (e.g. "go 1.25.0") acts as the effective floor. - if m := regexp.MustCompile(`(?m)^toolchain go1\.(\d+)`).FindSubmatch(data); m != nil { - return mustAtoi(t, string(m[1])) + if got, want := len(guarded), 1; got != want { + t.Fatalf("expected %d guarded file, got %d: %v", want, got, guarded) } - if m := regexp.MustCompile(`(?m)^go 1\.(\d+)`).FindSubmatch(data); m != nil { - return mustAtoi(t, string(m[1])) + if got, want := guarded["guarded.go"], "go1.99"; got != want { + t.Errorf("expected guarded.go to carry %q, got %q", want, got) } - - t.Fatalf("could not find a toolchain or go directive in %s", path) - - return 0 } -// maxAssertionGuardMinor textually scans every Go file in dir for //go:build go1.N guards -// and returns the highest minor version found (0 when none). -// -// The scan is textual (via go/parser, not go/packages) on purpose: a guard above the -// running toolchain would be excluded from a typed load, which is exactly the situation -// this invariant must detect. -func maxAssertionGuardMinor(t *testing.T, dir string) int { - t.Helper() - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("read dir %s: %v", dir, err) +// TestVerifyGuardedFilesLoaded covers the rail itself: a go-version-guarded file that exists +// on disk but never reached the typed load means the go command running the scan is older +// than the guard, and generating from that view would silently drop assertions. +func TestVerifyGuardedFilesLoaded(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + for name, content := range map[string]string{ + "plain.go": "package assertions\n", + "guarded.go": "//go:build go1.99\n\npackage assertions\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } } - fset := token.NewFileSet() - maxMinor := 0 - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || filepath.Ext(name) != ".go" { - continue - } + t.Run("guard dropped by an older toolchain", func(t *testing.T) { + t.Parallel() - file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments|parser.SkipObjectResolution) - if err != nil { - t.Fatalf("parse %s: %v", name, err) - } + pkg := &packages.Package{Dir: dir, GoFiles: []string{filepath.Join(dir, "plain.go")}} - constraintExpr := fileBuildConstraint(file) - if constraintExpr == "" { - continue + err := verifyGuardedFilesLoaded(pkg) + if !errors.Is(err, ErrGuardedFileDropped) { + t.Fatalf("expected ErrGuardedFileDropped, got %v", err) } - - for _, m := range goMinorRx.FindAllStringSubmatch(constraintExpr, -1) { - if minor := mustAtoi(t, m[1]); minor > maxMinor { - maxMinor = minor + for _, want := range []string{"guarded.go", "go1.99", "GOTOOLCHAIN=go1.99.0"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("expected the error to mention %q, got: %v", want, err) } } - } + }) - return maxMinor -} + t.Run("guard observed by the load", func(t *testing.T) { + t.Parallel() -func mustAtoi(t *testing.T, s string) int { - t.Helper() + pkg := &packages.Package{Dir: dir, GoFiles: []string{ + filepath.Join(dir, "plain.go"), + filepath.Join(dir, "guarded.go"), + }} - n, err := strconv.Atoi(s) - if err != nil { - t.Fatalf("invalid integer %q: %v", s, err) - } + if err := verifyGuardedFilesLoaded(pkg); err != nil { + t.Errorf("expected no error when every guarded file loaded, got %v", err) + } + }) - return n + t.Run("no package directory", func(t *testing.T) { + t.Parallel() + + if err := verifyGuardedFilesLoaded(&packages.Package{}); err != nil { + t.Errorf("expected no error without a package directory, got %v", err) + } + }) } diff --git a/docs/doc-site/_index.md b/docs/doc-site/_index.md index abe456d00..35de696a8 100644 --- a/docs/doc-site/_index.md +++ b/docs/doc-site/_index.md @@ -18,13 +18,14 @@ This is the go-openapi fork of the great [testify](https://github.com/stretchr/t ### Status {{% button href="https://github.com/go-openapi/testify/fork" hint="fork me on github" style=primary icon=code-fork %}}Fork me{{% /button %}} -Design and exploration phase. Feedback, contributions and proposals are welcome. +Design and exploration phase completed. The published API is now stable: +moving forward, API changes will remain backward-compatible with v2.4.0. See our [ROADMAP](./project/maintainers/ROADMAP.md). ### Motivation -See [why we wanted a v2](./MOTIVATION.md). +See [why we wanted a v2](./project/MOTIVATION.md). ### Getting started @@ -138,6 +139,21 @@ This library ships under the [SPDX-License-Identifier: Apache-2.0](./project/LIC See the license [NOTICE](./project/NOTICE.md), which recalls the licensing terms of all the pieces of software distributed with this fork, including internalized libraries. +--- + +We'd like to give credit and pay a big and loud thank you to the people who wrote these amazing pieces of software. +We maintain their original work here and continue improving it. + +* Mat Ryer, Tyler Bunnell and the `stretchr/testify` contributors - who made this new project possible +* thank you to the maintainers of that library, @ccoveille, @dolmen for their feedback and advice +* Dave Collins who wrote the spew library +* Patrick Mezard who wrote the difflib library - originally a go port from python's difflib + +Also special thanks to: + +* Gregory Petrosyan (@flyingmutant) - we use his amazing "rapid" library for our integration tests, with property-based testing +* the authors and maintainers of github.com/yaml/go-yaml, which we rely on for our YAML work + ## Contributing Feel free to submit issues, fork the repository and send pull requests! @@ -161,7 +177,7 @@ See also our [CONTRIBUTING guidelines](./project/contributing/CONTRIBUTING.md). - [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 +- [Benchmarks](./project/maintainers/BENCHMARKS.md) - Performance improvements in v2 **Reference:** - [API Reference](./api/_index.md) - Complete assertion catalog organized by domain diff --git a/docs/doc-site/api/_index.md b/docs/doc-site/api/_index.md index 57f53a52c..57ad75b96 100644 --- a/docs/doc-site/api/_index.md +++ b/docs/doc-site/api/_index.md @@ -24,17 +24,18 @@ with all documented exported variants documented in a more concise form than the ## Domains -The `testify` API is organized in 19 logical domains shown below. +The `testify` API is organized in 20 logical domains shown below. Each domain contains assertions regrouped by their use case (e.g. http, json, error). {{< children type="card" description="true" >}} --- +- [Async](./async.md) - Running Tests Asynchronously Against Go Routines (4) - [Boolean](./boolean.md) - Asserting Boolean Values (4) - [Collection](./collection.md) - Asserting Slices And Maps (23) - [Comparison](./comparison.md) - Comparing Ordered Values (12) -- [Condition](./condition.md) - Expressing Assertions Using Conditions (9) +- [Condition](./condition.md) - Expressing Assertions Using Conditions (5) - [Equality](./equality.md) - Asserting Two Things Are Equal (16) - [Error](./error.md) - Asserting Errors (11) - [File](./file.md) - Asserting OS Files (6) diff --git a/docs/doc-site/api/async.md b/docs/doc-site/api/async.md new file mode 100644 index 000000000..ee0b9557f --- /dev/null +++ b/docs/doc-site/api/async.md @@ -0,0 +1,1523 @@ +--- +title: "Async" +description: "Running Tests Asynchronously Against Go Routines" +weight: 1 +domains: + - "async" +keywords: + - "Consistently" + - "Consistentlyf" + - "Eventually" + - "Eventuallyf" + - "EventuallyWith" + - "EventuallyWithf" + - "Never" + - "Neverf" +--- + +Running Tests Asynchronously Against Go Routines + +## Assertions + +[![GoDoc][godoc-badge]][godoc-url] +{class="inline-badge"} + +_All links point to _ + +This domain exposes 4 functionalities. +Generic assertions are marked with a {{% icon icon="star" color=orange %}}. +Their method variants carry a {{% goversion "go1.27" %}} badge: methods take type +parameters only from go1.27 onwards, so on an older toolchain a generic assertion is available as a +package-level function alone. + +```tree +- [Consistently[C Conditioner]](#consistentlyc-conditioner) | star | orange +- [Eventually[C Conditioner]](#eventuallyc-conditioner) | star | orange +- [EventuallyWith[C CollectibleConditioner]](#eventuallywithc-collectibleconditioner) | star | orange +- [Never[C NeverConditioner]](#neverc-neverconditioner) | star | orange +``` + +### 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. + +#### Panic recovery + +A panicking condition is treated as an error, causing [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) to fail immediately. +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details. + +#### Concurrency + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +#### Attention point + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run +the polling loop inside a [testing/synctest] bubble, which uses a fake +clock. This eliminates timing-induced flakiness and makes the tick count +deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in +the condition, requires [*testing.T]). + +{{% 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, concurrency, and panic recovery. + success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond + failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond +``` +{{< /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 %}} + + +{{% 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 + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + result := assert.Consistently(t, assert.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %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 %}} + + +{{% 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 + + // An invariant that must hold throughout the observation period. + var counter atomic.Int32 + counter.Store(5) + invariant := func() bool { return counter.Load() < 10 } + + require.Consistently(t, require.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) + + fmt.Printf("invariant held: %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 | +| [`assert.(*Assertions).Consistently[C Conditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Consistently) | method variant {{% goversion "go1.27" %}} | +| [`assert.(*Assertions).Consistentlyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Consistentlyf) | method formatted variant {{% goversion "go1.27" %}} | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Consistentlyf) | formatted variant | +| [`require.(*Assertions).Consistently[C Conditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Consistently) | method variant {{% goversion "go1.27" %}} | +| [`require.(*Assertions).Consistentlyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Consistentlyf) | method formatted variant {{% goversion "go1.27" %}} | +{{% /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/async.go#L229) +{{% /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, at most until timeout, +or until the parent context of the test is cancelled. + +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 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. + +#### Panic recovery + +If the condition panics, the panic is recovered and treated as a failed tick +(equivalent to returning false or a non-nil error). For [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually), this means +the poller retries on the next tick — if a later tick succeeds, the assertion +succeeds. For [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) and [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently), a panic is treated as the condition +erroring, which causes immediate failure. + +The recovered panic is wrapped as an error with the sentinel [errConditionPanicked](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#errConditionPanicked), +detectable with [errors.Is](https://pkg.go.dev/errors#Is). + +#### Attention point + +Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +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). + +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run +the polling loop inside a [testing/synctest] bubble, which uses a fake +clock. This eliminates timing-induced flakiness and makes the tick count +deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in +the condition, requires `*testing.T`). + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + assertions.Eventually(t, func() bool { return true }, time.Second, 10*time.Millisecond) + 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 TestEventually(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 TestEventually(t *testing.T) + success := assert.Eventually(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 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 %}} + + +{{% 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 + + 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, assert.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /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 + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + result := assert.Eventually(t, assert.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /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 TestEventually(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 TestEventually(t *testing.T) + require.Eventually(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 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 %}} + + +{{% 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 + + 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, require.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) + + fmt.Printf("healthy: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /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 + + // A counter that converges on the 5th poll — no external time pressure. + var attempts atomic.Int32 + cond := func() bool { + return attempts.Add(1) == 5 + } + + // 1-hour/1-minute: under fake time this is instantaneous and + // deterministic — exactly 5 calls to the condition. + require.Eventually(t, require.WithSynctest(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("ready: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`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 | +| [`assert.(*Assertions).Eventually[C Conditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventually) | method variant {{% goversion "go1.27" %}} | +| [`assert.(*Assertions).Eventuallyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventuallyf) | method formatted variant {{% goversion "go1.27" %}} | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Eventuallyf) | formatted variant | +| [`require.(*Assertions).Eventually[C Conditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Eventually) | method variant {{% goversion "go1.27" %}} | +| [`require.(*Assertions).Eventuallyf[C Conditioner](condition C, timeout 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 {{% goversion "go1.27" %}} | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`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/async.go#L105) +{{% /tab %}} +{{< /tabs >}} + +### 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) +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 the timeout, the collected errors from the +last tick are copied to t. + +Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) (directly, or transitively through [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions) +fails the current tick only: the poller will retry on the next tick. This means +[require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require)-style assertions inside [EventuallyWith](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#EventuallyWith) behave naturally — they abort +the current evaluation and let the polling loop converge. + +To abort the whole assertion immediately (e.g. when the condition can no longer +be expected to succeed), call [CollectT.Cancel](https://pkg.go.dev/CollectT#Cancel). + +#### 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 is wrapped in its own goroutine, so a call to [runtime.Goexit](https://pkg.go.dev/runtime#Goexit) +(e.g. via [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions or [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow)) cleanly aborts only the +current tick. + +#### Panic recovery + +If the condition panics, the panic is recovered and recorded as an error in the +[CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#CollectT) for that tick. The poller treats it as a failed tick and retries on the +next one. If the assertion times out, the panic error is included in the collected +errors reported on the parent t. + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for the general panic recovery semantics. + +#### Synctest (opt-in) + +Wrap the condition with [WithSynctestCollect](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollect) (or [WithSynctestCollectContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollectContext)) +to run the polling loop inside a [testing/synctest] bubble, which uses +a fake clock. This eliminates timing-induced flakiness and makes the +tick count deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no +real I/O in the condition, requires [*testing.T]). + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + externalValue := false + go func() { + time.Sleep(8*time.Second) + externalValue = true + }() + assertions.EventuallyWith(t, func(c *assertions.CollectT) { + // add assertions as needed; any assertion failure will fail the current tick + assertions.True(c, externalValue, "expected 'externalValue' to be true") + }, + 10*time.Second, + 1*time.Second, + "external state has not changed to 'true'; still false", + ) + 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 + failure: func(c *CollectT) { c.Cancel() }, 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 TestEventuallyWith(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 TestEventuallyWith(t *testing.T) + success := assert.EventuallyWith(t, func(c *assert.CollectT) { + assert.True(c, 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 TestEventuallyWith(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 + + var attempts atomic.Int32 + cond := func(c *assert.CollectT) { + n := attempts.Add(1) + assert.Equal(c, int32(3), n, "not yet converged") + } + + result := assert.EventuallyWith(t, assert.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", result, attempts.Load()) + +} + +``` +{{% /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 TestEventuallyWith(t *testing.T) +package main + +import ( + "fmt" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // should come from testing, e.g. func TestEventuallyWith(t *testing.T) + require.EventuallyWith(t, func(c *assert.CollectT) { + assert.True(c, 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 TestEventuallyWith(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 + + var attempts atomic.Int32 + cond := func(c *require.CollectT) { + n := attempts.Add(1) + require.Equal(c, int32(3), n, "not yet converged") + } + + require.EventuallyWith(t, require.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) + + fmt.Printf("converged: %t, attempts: %d", !t.Failed(), attempts.Load()) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`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 | +| [`assert.(*Assertions).EventuallyWith[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWith) | method variant {{% goversion "go1.27" %}} | +| [`assert.(*Assertions).EventuallyWithf[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWithf) | method formatted variant {{% goversion "go1.27" %}} | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#EventuallyWithf) | formatted variant | +| [`require.(*Assertions).EventuallyWith[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.EventuallyWith) | method variant {{% goversion "go1.27" %}} | +| [`require.(*Assertions).EventuallyWithf[C CollectibleConditioner](condition C, timeout 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 {{% goversion "go1.27" %}} | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`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/async.go#L306) +{{% /tab %}} +{{< /tabs >}} + +### Never[C NeverConditioner] {{% icon icon="star" color=orange %}}{#neverc-neverconditioner} +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) ("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. + +#### Panic recovery + +A panicking condition is treated as an error, causing [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) to fail immediately. +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details. + +#### Concurrency + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +#### Attention point + +See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). + +#### Synctest (opt-in) + +Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) to run the polling loop inside a +[testing/synctest] bubble, which uses a fake clock. This eliminates +timing-induced flakiness and makes the tick count deterministic. See +[WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in the condition, +requires [*testing.T]). Note: [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) does not accept the context/error +form of condition, so [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext) does not apply here. + +{{% 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, concurrency, and panic recovery. + success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond + failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond +``` +{{< /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 TestNever(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 TestNever(t *testing.T) + success := assert.Never(t, func() bool { + return false + }, 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 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 %}} + + +{{% 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" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + result := assert.Never(t, assert.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %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 TestNever(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 TestNever(t *testing.T) + require.Never(t, func() bool { + return false + }, 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 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 %}} + + +{{% 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" + "sync/atomic" + "testing" + "time" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + t := new(testing.T) // normally provided by test + + // A flag that should remain false across the whole observation period. + var flipped atomic.Bool + require.Never(t, require.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) + + fmt.Printf("never flipped: %t", !t.Failed()) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.Never[C NeverConditioner](t T, condition C, 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[C NeverConditioner](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#Neverf) | formatted variant | +| [`assert.(*Assertions).Never[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Never) | method variant {{% goversion "go1.27" %}} | +| [`assert.(*Assertions).Neverf[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Neverf) | method formatted variant {{% goversion "go1.27" %}} | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Never) | package-level function | +| [`require.Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Neverf) | formatted variant | +| [`require.(*Assertions).Never[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Never) | method variant {{% goversion "go1.27" %}} | +| [`require.(*Assertions).Neverf[C NeverConditioner](condition C, 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 {{% goversion "go1.27" %}} | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`assertions.Never[C NeverConditioner](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#Never) | internal implementation | + +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Never](https://github.com/go-openapi/testify/blob/master/internal/assertions/async.go#L163) +{{% /tab %}} +{{< /tabs >}} + +--- + +--- + +Generated with github.com/go-openapi/testify/codegen/v2 + +[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/testify/v2 +[godoc-url]: https://pkg.go.dev/github.com/go-openapi/testify/v2 + + diff --git a/docs/doc-site/api/boolean.md b/docs/doc-site/api/boolean.md index 09bca551a..b70e63ea2 100644 --- a/docs/doc-site/api/boolean.md +++ b/docs/doc-site/api/boolean.md @@ -1,7 +1,7 @@ --- title: "Boolean" description: "Asserting Boolean Values" -weight: 1 +weight: 2 domains: - "boolean" keywords: diff --git a/docs/doc-site/api/collection.md b/docs/doc-site/api/collection.md index a5dd9cc12..d8ddcdb06 100644 --- a/docs/doc-site/api/collection.md +++ b/docs/doc-site/api/collection.md @@ -1,7 +1,7 @@ --- title: "Collection" description: "Asserting Slices And Maps" -weight: 2 +weight: 3 domains: - "collection" keywords: diff --git a/docs/doc-site/api/common.md b/docs/doc-site/api/common.md index 3e7b26867..c396e11f4 100644 --- a/docs/doc-site/api/common.md +++ b/docs/doc-site/api/common.md @@ -1,7 +1,7 @@ --- title: "Common" description: "Other Uncategorized Helpers" -weight: 19 +weight: 20 domains: - "common" keywords: diff --git a/docs/doc-site/api/comparison.md b/docs/doc-site/api/comparison.md index c14260da9..706436a5e 100644 --- a/docs/doc-site/api/comparison.md +++ b/docs/doc-site/api/comparison.md @@ -1,7 +1,7 @@ --- title: "Comparison" description: "Comparing Ordered Values" -weight: 3 +weight: 4 domains: - "comparison" keywords: diff --git a/docs/doc-site/api/condition.md b/docs/doc-site/api/condition.md index 46c9a8b4a..07f49ba41 100644 --- a/docs/doc-site/api/condition.md +++ b/docs/doc-site/api/condition.md @@ -1,7 +1,7 @@ --- title: "Condition" description: "Expressing Assertions Using Conditions" -weight: 4 +weight: 5 domains: - "condition" keywords: @@ -11,14 +11,6 @@ keywords: - "BlockedTf" - "Condition" - "Conditionf" - - "Consistently" - - "Consistentlyf" - - "Eventually" - - "Eventuallyf" - - "EventuallyWith" - - "EventuallyWithf" - - "Never" - - "Neverf" - "NotBlocked" - "NotBlockedf" - "NotBlockedT" @@ -34,7 +26,7 @@ Expressing Assertions Using Conditions _All links point to _ -This domain exposes 9 functionalities. +This domain exposes 5 functionalities. Generic assertions are marked with a {{% icon icon="star" color=orange %}}. Their method variants carry a {{% goversion "go1.27" %}} badge: methods take type parameters only from go1.27 onwards, so on an older toolchain a generic assertion is available as a @@ -44,10 +36,6 @@ package-level function alone. - [Blocked](#blocked) | angles-right - [BlockedT[E any, CHAN ~chan E]](#blockedte-any-chan-chan-e) | star | orange - [Condition](#condition) | angles-right -- [Consistently[C Conditioner]](#consistentlyc-conditioner) | star | orange -- [Eventually[C Conditioner]](#eventuallyc-conditioner) | star | orange -- [EventuallyWith[C CollectibleConditioner]](#eventuallywithc-collectibleconditioner) | star | orange -- [Never[C NeverConditioner]](#neverc-neverconditioner) | star | orange - [NotBlocked](#notblocked) | angles-right - [NotBlockedT[E any, CHAN ~chan E]](#notblockedte-any-chan-chan-e) | star | orange ``` @@ -162,7 +150,7 @@ func main() { |--|--| | [`assertions.Blocked(t T, ch any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#Blocked) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Blocked](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L56) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Blocked](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L48) {{% /tab %}} {{< /tabs >}} @@ -274,7 +262,7 @@ func main() { |--|--| | [`assertions.BlockedT[E any, CHAN ~chan E](t T, ch CHAN, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#BlockedT) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#BlockedT](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L104) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#BlockedT](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L96) {{% /tab %}} {{< /tabs >}} @@ -389,1475 +377,7 @@ func main() { |--|--| | [`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#L29) -{{% /tab %}} -{{< /tabs >}} - -### 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. - -#### Panic recovery - -A panicking condition is treated as an error, causing [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently) to fail immediately. -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details. - -#### Concurrency - -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). - -#### Attention point - -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). - -#### Synctest (opt-in) - -Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run -the polling loop inside a [testing/synctest] bubble, which uses a fake -clock. This eliminates timing-induced flakiness and makes the tick count -deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in -the condition, requires [*testing.T]). - -{{% 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, concurrency, and panic recovery. - success: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond - failure: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond -``` -{{< /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 %}} - - -{{% 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 - - // An invariant that must hold throughout the observation period. - var counter atomic.Int32 - counter.Store(5) - invariant := func() bool { return counter.Load() < 10 } - - result := assert.Consistently(t, assert.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) - - fmt.Printf("invariant held: %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 %}} - - -{{% 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 - - // An invariant that must hold throughout the observation period. - var counter atomic.Int32 - counter.Store(5) - invariant := func() bool { return counter.Load() < 10 } - - require.Consistently(t, require.WithSynctest(invariant), 1*time.Hour, 1*time.Minute) - - fmt.Printf("invariant held: %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 | -| [`assert.(*Assertions).Consistently[C Conditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Consistently) | method variant {{% goversion "go1.27" %}} | -| [`assert.(*Assertions).Consistentlyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Consistentlyf) | method formatted variant {{% goversion "go1.27" %}} | -{{% /tab %}} -{{% tab title="require" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`require.Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Consistentlyf) | formatted variant | -| [`require.(*Assertions).Consistently[C Conditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Consistently) | method variant {{% goversion "go1.27" %}} | -| [`require.(*Assertions).Consistentlyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Consistentlyf) | method formatted variant {{% goversion "go1.27" %}} | -{{% /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#L414) -{{% /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, at most until timeout, -or until the parent context of the test is cancelled. - -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 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. - -#### Panic recovery - -If the condition panics, the panic is recovered and treated as a failed tick -(equivalent to returning false or a non-nil error). For [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually), this means -the poller retries on the next tick — if a later tick succeeds, the assertion -succeeds. For [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) and [Consistently](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Consistently), a panic is treated as the condition -erroring, which causes immediate failure. - -The recovered panic is wrapped as an error with the sentinel [errConditionPanicked](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#errConditionPanicked), -detectable with [errors.Is](https://pkg.go.dev/errors#Is). - -#### Attention point - -Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce -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). - -#### Synctest (opt-in) - -Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) (or [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext)) to run -the polling loop inside a [testing/synctest] bubble, which uses a fake -clock. This eliminates timing-induced flakiness and makes the tick count -deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in -the condition, requires `*testing.T`). - -{{% expand title="Examples" %}} -{{< tabs >}} -{{% tab title="Usage" %}} -```go - assertions.Eventually(t, func() bool { return true }, time.Second, 10*time.Millisecond) - 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 TestEventually(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 TestEventually(t *testing.T) - success := assert.Eventually(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 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 %}} - - -{{% 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 - - 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, assert.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) - - fmt.Printf("healthy: %t, attempts: %d", result, attempts.Load()) - -} - -``` -{{% /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 - - // A counter that converges on the 5th poll — no external time pressure. - var attempts atomic.Int32 - cond := func() bool { - return attempts.Add(1) == 5 - } - - // 1-hour/1-minute: under fake time this is instantaneous and - // deterministic — exactly 5 calls to the condition. - result := assert.Eventually(t, assert.WithSynctest(cond), 1*time.Hour, 1*time.Minute) - - fmt.Printf("ready: %t, attempts: %d", result, attempts.Load()) - -} - -``` -{{% /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 TestEventually(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 TestEventually(t *testing.T) - require.Eventually(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 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 %}} - - -{{% 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 - - 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, require.WithSynctestContext(healthCheck), 1*time.Hour, 1*time.Minute) - - fmt.Printf("healthy: %t, attempts: %d", !t.Failed(), attempts.Load()) - -} - -``` -{{% /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 - - // A counter that converges on the 5th poll — no external time pressure. - var attempts atomic.Int32 - cond := func() bool { - return attempts.Add(1) == 5 - } - - // 1-hour/1-minute: under fake time this is instantaneous and - // deterministic — exactly 5 calls to the condition. - require.Eventually(t, require.WithSynctest(cond), 1*time.Hour, 1*time.Minute) - - fmt.Printf("ready: %t, attempts: %d", !t.Failed(), attempts.Load()) - -} - -``` -{{% /card %}} - - -{{% /cards %}} -{{< /tab >}} - - -{{< /tabs >}} -{{% /expand %}} - -{{< tabs >}} - -{{% tab title="assert" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`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 | -| [`assert.(*Assertions).Eventually[C Conditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventually) | method variant {{% goversion "go1.27" %}} | -| [`assert.(*Assertions).Eventuallyf[C Conditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Eventuallyf) | method formatted variant {{% goversion "go1.27" %}} | -{{% /tab %}} -{{% tab title="require" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`require.Eventually[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Eventuallyf) | formatted variant | -| [`require.(*Assertions).Eventually[C Conditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Eventually) | method variant {{% goversion "go1.27" %}} | -| [`require.(*Assertions).Eventuallyf[C Conditioner](condition C, timeout 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 {{% goversion "go1.27" %}} | -{{% /tab %}} - -{{% tab title="internal" style="accent" icon="wrench" %}} -| Signature | Usage | -|--|--| -| [`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#L290) -{{% /tab %}} -{{< /tabs >}} - -### 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) -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 the timeout, the collected errors from the -last tick are copied to t. - -Calling [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow) (directly, or transitively through [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions) -fails the current tick only: the poller will retry on the next tick. This means -[require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require)-style assertions inside [EventuallyWith](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#EventuallyWith) behave naturally — they abort -the current evaluation and let the polling loop converge. - -To abort the whole assertion immediately (e.g. when the condition can no longer -be expected to succeed), call [CollectT.Cancel](https://pkg.go.dev/CollectT#Cancel). - -#### 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 is wrapped in its own goroutine, so a call to [runtime.Goexit](https://pkg.go.dev/runtime#Goexit) -(e.g. via [require](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#require) assertions or [CollectT.FailNow](https://pkg.go.dev/CollectT#FailNow)) cleanly aborts only the -current tick. - -#### Panic recovery - -If the condition panics, the panic is recovered and recorded as an error in the -[CollectT](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#CollectT) for that tick. The poller treats it as a failed tick and retries on the -next one. If the assertion times out, the panic error is included in the collected -errors reported on the parent t. - -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for the general panic recovery semantics. - -#### Synctest (opt-in) - -Wrap the condition with [WithSynctestCollect](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollect) (or [WithSynctestCollectContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestCollectContext)) -to run the polling loop inside a [testing/synctest] bubble, which uses -a fake clock. This eliminates timing-induced flakiness and makes the -tick count deterministic. See [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no -real I/O in the condition, requires [*testing.T]). - -{{% expand title="Examples" %}} -{{< tabs >}} -{{% tab title="Usage" %}} -```go - externalValue := false - go func() { - time.Sleep(8*time.Second) - externalValue = true - }() - assertions.EventuallyWith(t, func(c *assertions.CollectT) { - // add assertions as needed; any assertion failure will fail the current tick - assertions.True(c, externalValue, "expected 'externalValue' to be true") - }, - 10*time.Second, - 1*time.Second, - "external state has not changed to 'true'; still false", - ) - 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 - failure: func(c *CollectT) { c.Cancel() }, 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 TestEventuallyWith(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 TestEventuallyWith(t *testing.T) - success := assert.EventuallyWith(t, func(c *assert.CollectT) { - assert.True(c, 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 TestEventuallyWith(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 - - var attempts atomic.Int32 - cond := func(c *assert.CollectT) { - n := attempts.Add(1) - assert.Equal(c, int32(3), n, "not yet converged") - } - - result := assert.EventuallyWith(t, assert.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) - - fmt.Printf("converged: %t, attempts: %d", result, attempts.Load()) - -} - -``` -{{% /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 TestEventuallyWith(t *testing.T) -package main - -import ( - "fmt" - "testing" - "time" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func main() { - t := new(testing.T) // should come from testing, e.g. func TestEventuallyWith(t *testing.T) - require.EventuallyWith(t, func(c *assert.CollectT) { - assert.True(c, 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 TestEventuallyWith(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 - - var attempts atomic.Int32 - cond := func(c *require.CollectT) { - n := attempts.Add(1) - require.Equal(c, int32(3), n, "not yet converged") - } - - require.EventuallyWith(t, require.WithSynctestCollect(cond), 1*time.Hour, 1*time.Minute) - - fmt.Printf("converged: %t, attempts: %d", !t.Failed(), attempts.Load()) - -} - -``` -{{% /card %}} - - -{{% /cards %}} -{{< /tab >}} - - -{{< /tabs >}} -{{% /expand %}} - -{{< tabs >}} - -{{% tab title="assert" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`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 | -| [`assert.(*Assertions).EventuallyWith[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWith) | method variant {{% goversion "go1.27" %}} | -| [`assert.(*Assertions).EventuallyWithf[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.EventuallyWithf) | method formatted variant {{% goversion "go1.27" %}} | -{{% /tab %}} -{{% tab title="require" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`require.EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](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)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#EventuallyWithf) | formatted variant | -| [`require.(*Assertions).EventuallyWith[C CollectibleConditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.EventuallyWith) | method variant {{% goversion "go1.27" %}} | -| [`require.(*Assertions).EventuallyWithf[C CollectibleConditioner](condition C, timeout 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 {{% goversion "go1.27" %}} | -{{% /tab %}} - -{{% tab title="internal" style="accent" icon="wrench" %}} -| Signature | Usage | -|--|--| -| [`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#L491) -{{% /tab %}} -{{< /tabs >}} - -### Never[C NeverConditioner] {{% icon icon="star" color=orange %}}{#neverc-neverconditioner} -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) ("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. - -#### Panic recovery - -A panicking condition is treated as an error, causing [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) to fail immediately. -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually) for details. - -#### Concurrency - -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). - -#### Attention point - -See [Eventually](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Eventually). - -#### Synctest (opt-in) - -Wrap the condition with [WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) to run the polling loop inside a -[testing/synctest] bubble, which uses a fake clock. This eliminates -timing-induced flakiness and makes the tick count deterministic. See -[WithSynctest](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctest) for the constraints (no real I/O in the condition, -requires [*testing.T]). Note: [Never](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Never) does not accept the context/error -form of condition, so [WithSynctestContext](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#WithSynctestContext) does not apply here. - -{{% 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, concurrency, and panic recovery. - success: func() bool { return false }, 100*time.Millisecond, 20*time.Millisecond - failure: func() bool { return true }, 100*time.Millisecond, 20*time.Millisecond -``` -{{< /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 TestNever(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 TestNever(t *testing.T) - success := assert.Never(t, func() bool { - return false - }, 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 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 %}} - - -{{% 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" - "sync/atomic" - "testing" - "time" - - "github.com/go-openapi/testify/v2/assert" -) - -func main() { - t := new(testing.T) // normally provided by test - - // A flag that should remain false across the whole observation period. - var flipped atomic.Bool - result := assert.Never(t, assert.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) - - fmt.Printf("never flipped: %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 TestNever(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 TestNever(t *testing.T) - require.Never(t, func() bool { - return false - }, 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 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 %}} - - -{{% 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" - "sync/atomic" - "testing" - "time" - - "github.com/go-openapi/testify/v2/require" -) - -func main() { - t := new(testing.T) // normally provided by test - - // A flag that should remain false across the whole observation period. - var flipped atomic.Bool - require.Never(t, require.WithSynctest(flipped.Load), 1*time.Hour, 1*time.Minute) - - fmt.Printf("never flipped: %t", !t.Failed()) - -} - -``` -{{% /card %}} - - -{{% /cards %}} -{{< /tab >}} - - -{{< /tabs >}} -{{% /expand %}} - -{{< tabs >}} - -{{% tab title="assert" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`assert.Never[C NeverConditioner](t T, condition C, 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[C NeverConditioner](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#Neverf) | formatted variant | -| [`assert.(*Assertions).Never[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Never) | method variant {{% goversion "go1.27" %}} | -| [`assert.(*Assertions).Neverf[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.Neverf) | method formatted variant {{% goversion "go1.27" %}} | -{{% /tab %}} -{{% tab title="require" style="secondary" %}} -| Signature | Usage | -|--|--| -| [`require.Never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Never) | package-level function | -| [`require.Neverf[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msg string, args ...any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Neverf) | formatted variant | -| [`require.(*Assertions).Never[C NeverConditioner](condition C, timeout time.Duration, tick time.Duration)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.Never) | method variant {{% goversion "go1.27" %}} | -| [`require.(*Assertions).Neverf[C NeverConditioner](condition C, 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 {{% goversion "go1.27" %}} | -{{% /tab %}} - -{{% tab title="internal" style="accent" icon="wrench" %}} -| Signature | Usage | -|--|--| -| [`assertions.Never[C NeverConditioner](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#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#L348) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#Condition](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L21) {{% /tab %}} {{< /tabs >}} @@ -1989,7 +509,7 @@ func sendChanMessage() chan struct{} { |--|--| | [`assertions.NotBlocked(t T, ch any, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotBlocked) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotBlocked](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L141) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotBlocked](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L133) {{% /tab %}} {{< /tabs >}} @@ -2119,7 +639,7 @@ func sendChanMessage() chan struct{} { |--|--| | [`assertions.NotBlockedT[E any, CHAN ~chan E](t T, ch CHAN, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotBlockedT) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotBlockedT](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L188) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotBlockedT](https://github.com/go-openapi/testify/blob/master/internal/assertions/condition.go#L180) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/api/equality.md b/docs/doc-site/api/equality.md index eb4170f30..09e85dc23 100644 --- a/docs/doc-site/api/equality.md +++ b/docs/doc-site/api/equality.md @@ -1,7 +1,7 @@ --- title: "Equality" description: "Asserting Two Things Are Equal" -weight: 5 +weight: 6 domains: - "equality" keywords: diff --git a/docs/doc-site/api/error.md b/docs/doc-site/api/error.md index 684290ff0..51184cb73 100644 --- a/docs/doc-site/api/error.md +++ b/docs/doc-site/api/error.md @@ -1,7 +1,7 @@ --- title: "Error" description: "Asserting Errors" -weight: 6 +weight: 7 domains: - "error" keywords: diff --git a/docs/doc-site/api/file.md b/docs/doc-site/api/file.md index d8fe78b31..96dfc188a 100644 --- a/docs/doc-site/api/file.md +++ b/docs/doc-site/api/file.md @@ -1,7 +1,7 @@ --- title: "File" description: "Asserting OS Files" -weight: 7 +weight: 8 domains: - "file" keywords: diff --git a/docs/doc-site/api/http.md b/docs/doc-site/api/http.md index 9aea4b689..b6daee20d 100644 --- a/docs/doc-site/api/http.md +++ b/docs/doc-site/api/http.md @@ -1,7 +1,7 @@ --- title: "Http" description: "Asserting HTTP Response And Body" -weight: 8 +weight: 9 domains: - "http" keywords: diff --git a/docs/doc-site/api/json.md b/docs/doc-site/api/json.md index 4d5e456ac..7eb74f9a4 100644 --- a/docs/doc-site/api/json.md +++ b/docs/doc-site/api/json.md @@ -1,7 +1,7 @@ --- title: "Json" description: "Asserting JSON Documents" -weight: 9 +weight: 10 domains: - "json" keywords: diff --git a/docs/doc-site/api/metrics.md b/docs/doc-site/api/metrics.md index e2b7d4d41..2638c8767 100644 --- a/docs/doc-site/api/metrics.md +++ b/docs/doc-site/api/metrics.md @@ -7,7 +7,7 @@ weight: -1 ## Domains -All assertions are classified into **19** domains to help navigate the API, depending on your use case. +All assertions are classified into **20** domains to help navigate the API, depending on your use case. ## API metrics @@ -38,7 +38,7 @@ Table of core assertions, excluding variants. Each function is side by side with | [BlockedT[E any, CHAN ~chan E]](condition/#blockedte-any-chan-chan-e) {{% icon icon="star" color=orange %}} | [NotBlockedT](condition/#notblockedte-any-chan-chan-e) | condition | | | [CallerInfo](common/#callerinfo) | | common | helper | | [Condition](condition/#condition) | | condition | | -| [Consistently[C Conditioner]](condition/#consistentlyc-conditioner) {{% icon icon="star" color=orange %}} | | condition | | +| [Consistently[C Conditioner]](async/#consistentlyc-conditioner) {{% icon icon="star" color=orange %}} | | async | | | [Contains](collection/#contains) | [NotContains](collection/#notcontains) | collection | | | [DirExists](file/#direxists) | [DirNotExists](file/#dirnotexists) | file | | | [ElementsMatch](collection/#elementsmatch) | [NotElementsMatch](collection/#notelementsmatch) | collection | | @@ -54,8 +54,8 @@ Table of core assertions, excluding variants. Each function is side by side with | [ErrorAsType[E error]](error/#errorastypee-error) {{% icon icon="star" color=orange %}} | [NotErrorAsType](error/#noterrorastypee-error) | error | | | [ErrorContains](error/#errorcontains) | [ErrorNotContains](error/#errornotcontains) | error | | | [ErrorIs](error/#erroris) | [NotErrorIs](error/#noterroris) | error | | -| [EventuallyWith[C CollectibleConditioner]](condition/#eventuallywithc-collectibleconditioner) {{% icon icon="star" color=orange %}} | | condition | | -| [Eventually[C Conditioner]](condition/#eventuallyc-conditioner) {{% icon icon="star" color=orange %}} | [Never](condition/#neverc-neverconditioner) | condition | | +| [EventuallyWith[C CollectibleConditioner]](async/#eventuallywithc-collectibleconditioner) {{% icon icon="star" color=orange %}} | | async | | +| [Eventually[C Conditioner]](async/#eventuallyc-conditioner) {{% icon icon="star" color=orange %}} | [Never](async/#neverc-neverconditioner) | async | | | [Exactly](equality/#exactly) | | equality | | | [Fail](testing/#fail) | | testing | | | [FailNow](testing/#failnow) | | testing | | diff --git a/docs/doc-site/api/number.md b/docs/doc-site/api/number.md index aef3e2f03..43b0ad44a 100644 --- a/docs/doc-site/api/number.md +++ b/docs/doc-site/api/number.md @@ -1,7 +1,7 @@ --- title: "Number" description: "Asserting Numbers" -weight: 10 +weight: 11 domains: - "number" keywords: diff --git a/docs/doc-site/api/ordering.md b/docs/doc-site/api/ordering.md index a3e8488c5..83295ba59 100644 --- a/docs/doc-site/api/ordering.md +++ b/docs/doc-site/api/ordering.md @@ -1,7 +1,7 @@ --- title: "Ordering" description: "Asserting How Collections Are Ordered" -weight: 11 +weight: 12 domains: - "ordering" keywords: diff --git a/docs/doc-site/api/panic.md b/docs/doc-site/api/panic.md index 9282c9dc5..63d03fef2 100644 --- a/docs/doc-site/api/panic.md +++ b/docs/doc-site/api/panic.md @@ -1,7 +1,7 @@ --- title: "Panic" description: "Asserting A Panic Behavior" -weight: 12 +weight: 13 domains: - "panic" keywords: diff --git a/docs/doc-site/api/safety.md b/docs/doc-site/api/safety.md index cc31e2c71..392f63769 100644 --- a/docs/doc-site/api/safety.md +++ b/docs/doc-site/api/safety.md @@ -1,7 +1,7 @@ --- title: "Safety" description: "Checks Against Leaked Resources (Goroutines, File Descriptors)" -weight: 13 +weight: 14 domains: - "safety" keywords: diff --git a/docs/doc-site/api/string.md b/docs/doc-site/api/string.md index 9f0e6b7d5..fd6500e8c 100644 --- a/docs/doc-site/api/string.md +++ b/docs/doc-site/api/string.md @@ -1,7 +1,7 @@ --- title: "String" description: "Asserting Strings" -weight: 14 +weight: 15 domains: - "string" keywords: diff --git a/docs/doc-site/api/testing.md b/docs/doc-site/api/testing.md index 5e6eb2501..a1c899464 100644 --- a/docs/doc-site/api/testing.md +++ b/docs/doc-site/api/testing.md @@ -1,7 +1,7 @@ --- title: "Testing" description: "Mimics Methods From The Testing Standard Library" -weight: 15 +weight: 16 domains: - "testing" keywords: diff --git a/docs/doc-site/api/time.md b/docs/doc-site/api/time.md index 79ccbd10c..b12df5af0 100644 --- a/docs/doc-site/api/time.md +++ b/docs/doc-site/api/time.md @@ -1,7 +1,7 @@ --- title: "Time" description: "Asserting Times And Durations" -weight: 16 +weight: 17 domains: - "time" keywords: diff --git a/docs/doc-site/api/type.md b/docs/doc-site/api/type.md index 24fe24693..1a07510e8 100644 --- a/docs/doc-site/api/type.md +++ b/docs/doc-site/api/type.md @@ -1,7 +1,7 @@ --- title: "Type" description: "Asserting Types Rather Than Values" -weight: 17 +weight: 18 domains: - "type" keywords: diff --git a/docs/doc-site/api/yaml.md b/docs/doc-site/api/yaml.md index dd68c0038..8e13457e2 100644 --- a/docs/doc-site/api/yaml.md +++ b/docs/doc-site/api/yaml.md @@ -1,7 +1,7 @@ --- title: "Yaml" description: "Asserting Yaml Documents" -weight: 18 +weight: 19 domains: - "yaml" keywords: @@ -217,7 +217,7 @@ NOTE: passed expected value may be wrapped as a function to redact the input tex {{< tabs >}} {{% tab title="Usage" %}} ```go - actual := struct { + expected := struct { A int `yaml:"a"` }{ A: 10, diff --git a/docs/doc-site/project/APPROACH.md b/docs/doc-site/project/APPROACH.md index e3b2da648..1c163841e 100644 --- a/docs/doc-site/project/APPROACH.md +++ b/docs/doc-site/project/APPROACH.md @@ -240,6 +240,11 @@ var _ = ginkgo.Describe("User creation", func() { **The debate continues** across all programming communities. Neither style is objectively superior; they optimize for different values and team preferences. +> My two-cents: I prefer assertion-style for unit and integration tests handled by development teams, +> where code clarity prevails. +> +> I resort to BDD-style for tests driven by QA or UAT teams, where using natural language helps. + --- ## Assertion-Style and Go Values @@ -441,6 +446,45 @@ question for Go developers is: **which style aligns with the values that drew yo --- +## Beyond style, approaches + +Style is after all not that important. Perhaps more interesting is to consider alternative approaches to construct +tests and test harnesses. Here are a few fascinating approaches + +**property-based testing** + +In this project, we've successfully experimented a _property-based testing_ approach to validate more systematically +a few packages (see `internal/testintegration`). + +This is using the excellent library [`rapid`](https://pkg.go.dev/pgregory.net/rapid) to produce random structures +submitted to `spew.Dump` and spotted quite a few actual bugs in there. + +**fuzzing** + +Also a randomized approach to testing. The fuzz driver that comes with the standard toolchain is smart: it selects +the random candidates and track their code coverage path, so it biases its sampling toward exploring more code paths. + +Fuzzing may be nicely coupled with property-based testing. Again, feel free to take a peek at our integration tests. + +If you are interested, you may look at how fuzz tests are implemented in this project. We've also equipped our CI pipeline +with a caching of the fuzz corpus and retrieval of captured failures. + +**mutesting** + +The "mutation testing" approach is more about assessing the quality of your tests, complementary to test coverage, +rather than about testing functionality. + +The principle is to inject bugs randomly in your code and verify that your test suite actually catch them: +good tests are tests that catch bugs, not tests that walk 100% of the code and do not verify anything. + +Unfortunately, support is still experimental for `golang`, although many languages already come with decent support for +this technique. + +If you're interested, provides an old but convincing experience. This fork would love to continue +the work , but we are currently a bit short of time to implement the ideas behind it. + +--- + ## See also - [API Reference](../api/) - Browse all {{% siteparam "metrics.assertions" %}} assertions by domain diff --git a/docs/doc-site/project/LICENSE.md b/docs/doc-site/project/LICENSE.md index cfc0d4c38..15d128fdf 100644 --- a/docs/doc-site/project/LICENSE.md +++ b/docs/doc-site/project/LICENSE.md @@ -1,7 +1,7 @@ --- title: LICENSE description: Apache-2.0 License -weight: 10 +weight: 30 --- ``` Apache License diff --git a/docs/doc-site/project/MOTIVATION.md b/docs/doc-site/project/MOTIVATION.md index 34c77598c..4a9fd4903 100644 --- a/docs/doc-site/project/MOTIVATION.md +++ b/docs/doc-site/project/MOTIVATION.md @@ -16,6 +16,11 @@ With this fork, we want to: 2. [x] make it easy to maintain and extend. 3. [x] pare down some of the chrome that has been added over the years. +As of September 2026, we think all the objectives of the fork have been achieved. +It is now widely adopted across all go-openapi and go-swagger github projects. TL;DR: it just works. + +The project keeps adding features and fixes. We continue tracking upstream at least quarterly. Steady and easy. + --- {{% notice style="primary" title="Extended hand" icon="hand" %}} diff --git a/docs/doc-site/project/NOTICE.md b/docs/doc-site/project/NOTICE.md index 3edd10e28..0cf2474e2 100644 --- a/docs/doc-site/project/NOTICE.md +++ b/docs/doc-site/project/NOTICE.md @@ -1,7 +1,7 @@ --- title: NOTICE description: Code attribution and other LICENSE terms -weight: 11 +weight: 50 --- ``` // SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers diff --git a/docs/doc-site/project/README.md b/docs/doc-site/project/README.md index dc7dc320e..e2be6eea7 100644 --- a/docs/doc-site/project/README.md +++ b/docs/doc-site/project/README.md @@ -17,7 +17,7 @@ weight: 2 * 95% compatible with `stretchr/testify` — if you already use it, our migration tool automates the switch * Actively maintained: regular fixes and evolutions, many PRs proposed upstream are already in * Zero external dependencies — you import what you need, with opt-in modules for extras (e.g. YAML, colorized output) -* Modernized codebase targeting go1.25+ +* Modernized codebase targeting go1.26+ * Go routine leak detection built in: zero-setup, no false positives, works with parallel tests (unlike `go.uber.org/goleak`) * File descriptor leak detection (linux-only) * Type-safe assertions with generics (see [a basic example](../usage/EXAMPLES.md)) — migration to generics can be automated too. [Read the full story](../usage/GENERICS.md) @@ -28,7 +28,7 @@ weight: 2 ### This fork isn't for everyone * You need the `mock` package — we removed it and won't bring it back. For suites, we're [open to discussion](https://github.com/go-openapi/testify/discussions/75) about a redesigned approach -* Your project must support Go versions older than 1.25 +* Your project must support Go versions older than 1.26 * You rely on `testifylint` or other tooling that expects the `stretchr/testify` import path * You need 100% API compatibility — we're at 95%, and the remaining 5% are intentional removals diff --git a/docs/doc-site/project/maintainers/ARCHITECTURE.md b/docs/doc-site/project/maintainers/ARCHITECTURE.md index a2a693cbf..2e031cda6 100644 --- a/docs/doc-site/project/maintainers/ARCHITECTURE.md +++ b/docs/doc-site/project/maintainers/ARCHITECTURE.md @@ -13,29 +13,49 @@ We want the maintenance of dozens of test assertions, times many variants, to re The maintenance flow is intended to require different activities and levels of understanding, depending on the complexity of a planned evolution. +Not everything can be hassle-free, but the design should offer a relatively easy path to most common maintenance. + +After 9 months of all sorts of maintenance tasks, the diagram below gives a rather faithful representation of what +it actually costs. + {{< mermaid align="center" zoom="true" >}} -journey - section Fixes & minor enhancements - internal/assertions:5: Knowledge of the functionality - section New dependencies - internal/assertions/enable/...:5: Understanding of the repo architecture - enable/...:5: Understanding of the repo architecture - section API changes - regenerate code:5: No specific knowledge - section New constructs to support - code & doc generator:5: Knowledge of internals +quadrantChart + title Change complexity vs Required knowledge + x-axis Minimal Knowledge --> In-Depth Understanding + y-axis Simple Change --> Complex Change + + quadrant-3 Hassle-free + quadrant-2 Follow the tracks + quadrant-4 Should stay empty + quadrant-1 Generator work + + Bug fixes: [0.2, 0.2] + Doc fixes: [0.25, 0.1] + Minor enhancements: [0.3, 0.4] + New assertions: [0.35, 0.23] + New dependencies: [0.20, 0.70] + New constructs: [0.78, 0.78] + Guarded assertions: [0.40, 0.58] + Package layout: [0.68, 0.62] + {{< /mermaid >}} +The bottom-right quadrant is empty on purpose: nothing here asks for in-depth knowledge of the +generator to make a small change. A point landing there means the code needs reshaping. + Most common maintenance tasks should not require much more than fixing/enhancing the code in `internal/assertions`. -API changes need an extra code generation. +Fixes and enhancements propagate naturally to the variants without the need to regenerate code. + +API changes need an extra code generation, but no specific knowledge of the generator itself. -Dependency changes (adding new features that need extra dependencies) is a bit more involved, but still manageable. +Dependency changes (adding new features that need extra dependencies) is a bit more involved, but still manageable: +the pattern is regular, follow the tracks. -The code & doc generator should rapidly become a very stable component. The maintenance of the generator itself remains +The code & doc generator has now become a rather stable component. The maintenance of the generator itself remains an operation that requires an extended understanding of the internals of the project. -Fixes and enhancements propagate naturally to the variants without the need to regenerate code. +Example of recent updates that required such in-depth maintenance: adding support for build guards. ### The maths with assertion variants @@ -48,10 +68,11 @@ Each of these variants produces another formatted variant, plus one "forward" va **For every "helper" function (not an assertion): 2 variants.** -Generic assertions reach 8 variants only from go1.27, the first release that accepts type parameters -on methods. Their 4 method variants are generated into files guarded by `//go:build go1.27` -(`assert/assert_forward_go127.go`, `require/require_forward_go127.go`), so a go1.25 or go1.26 build -drops them and keeps the 4 package-level variants. The counts below assume go1.27. +> Generic assertions reach 8 variants only from go1.27, the first release that accepts type parameters on methods. +> +> Their 4 method variants are generated into files guarded by `//go:build go1.27` +> (`assert/assert_forward_go127.go`, `require/require_forward_go127.go`), so a go1.26 build +> drops them and keeps the 4 package-level variants. The counts below assume go1.27. All these variants make up several hundreds functions, which poses a challenge for maintenance and documentation. @@ -124,8 +145,8 @@ Exceptions: The `enable/` package provides optional features that users can activate via blank imports: - `enable/stubs/` - Public stub APIs for enabling features (yaml, colors) -- `enable/yaml/` - Activates YAML support via `import _ "github.com/go-openapi/testify/v2/enable/yaml"` -- `enable/colors/` - Activates colorized output via `import _ "github.com/go-openapi/testify/v2/enable/colors"` +- `enable/yaml/` - Activates YAML support via `import _ "github.com/go-openapi/testify/enable/yaml/v2"` +- `enable/colors/` - Activates colorized output via `import _ "github.com/go-openapi/testify/enable/colors/v2"` These packages are not generated and allow optional dependencies to be isolated from the core library. diff --git a/docs/doc-site/project/maintainers/CODEGEN.md b/docs/doc-site/project/maintainers/CODEGEN.md index 94411f640..3318de89a 100644 --- a/docs/doc-site/project/maintainers/CODEGEN.md +++ b/docs/doc-site/project/maintainers/CODEGEN.md @@ -163,7 +163,7 @@ graph TD style require_group fill:#ffb6c1,color:#000 {{< /mermaid >}} -> **reflection-based assertions become 8, generic assertions become 4** +> **reflection-based assertions become 8, generic assertions become 4 + 4 more available on go1.27)** > > (plus tests and documentation for each). @@ -251,21 +251,198 @@ To cover these edge cases, a `relocate` function map currently rewrites the exam from an external package. The relocation uses go parsing capabilities. The only hard-coded exception if for `PanicFunc`. (see `codegen/internal/generator/funcmap.go`). +--- + +### Adding an assertion with go version gate + +Some assertions need a standard library function that only exists from a given Go release on. +`ErrorAsType` was the first: it wraps `errors.AsType`, added in go1.26, at a time when the +library still supported go1.25. + +Build constraints in Go apply to a whole file, so such an assertion gets its own source file, +and the generator replicates that file's `//go:build` line onto a parallel set of generated +files. Users on an older toolchain never see the file: it drops out before the compiler reads it. + +The walkthrough below follows what we did for `ErrorAsType` in v2.6. The gate is gone since +v2.7 raised the floor to go1.26 (PR #164), so read the recipe here rather than in the tree. + +**1. Write the assertion in its own guarded file.** + +Name it `internal/assertions/_go1NN.go` — `error_go126.go` for `ErrorAsType` — and put +the constraint between the SPDX header and the package clause, with a blank line on each side: + +```go +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build go1.26 + +package assertions +``` + +Write `go1.26`, not `1.26`; the latter parses as a plain tag name and gates nothing. + +Everything else stays as in [Adding a New Assertion](#adding-a-new-assertion): the same +`// Domain: error` tag, the same `Examples:` block. The domain tag decides which page the +assertion documents on, so a guarded assertion lands next to its unguarded siblings. +Say in the doc comment that the assertion needs the newer toolchain — the generated variants +copy the comment verbatim to `pkg.go.dev`, where nothing else marks the constraint. + +**2. Regenerate on a toolchain that can see the guard.** + +The generator never reads the guarded file itself. `packages.Load` shells out to `go list` in +the work-dir, and that subprocess decides which files exist. Run it on a toolchain older than +the guard and the file is simply absent: the assertion disappears from `assert/`, from +`require/` and from the doc site, with no error and nothing in the diff to catch the eye. + +Two things that look like they would protect you, and do not: + +- **The toolchain you built the generator with.** The binary carries no toolchain. Build it + with go1.27 and run it with go1.26 on `PATH` and the scan still sees a go1.26 view. +- **The `toolchain` directive in `codegen/go.mod`.** A module's directive is ignored in + workspace mode, and the scan's `go list` runs in the repository root with the workspace + active. That directive covers standalone use — `go run github.com/go-openapi/testify/codegen/v2@latest`, + or `GOWORK=off` inside `codegen/` — and nothing else. + +So name the toolchain on the regeneration command itself: + +```bash +cd codegen +go build -o codegen . +GOTOOLCHAIN=go1.26.0 ./codegen +``` + +**Do not raise the `go.work` toolchain line to cover a guard.** That line applies to every +command run in the workspace, and CI runs the whole `oldstable`/`stable` matrix through +`go test work ./...`. Pin the workspace and both matrix entries build the same toolchain, so +the older one stops exercising the exclusion path — which is the one property the guard exists +to protect. + +{{% notice tip "The generator refuses an incomplete scan" "shield" %}} +> You do not have to remember this. `verifyGuardedFilesLoaded` (`codegen/internal/scanner/buildtags.go`) +> reads the package directory textually, compares the `//go:build go1.N` files it finds there against +> the files the load actually returned, and stops the run when one is missing: +> +> ``` +> guarded source file missing from the scan: error_go127.go (//go:build go1.27). The go command +> running the scan is older than these guards ... Rerun with GOTOOLCHAIN=go1.27.0 +> ``` +> +> The scan on disk has to be textual: a file guarded above the running toolchain never appears in the +> typed package, so the typed view cannot report what it is missing. Nothing is generated before the +> check passes. Only a plain `go1.N` constraint counts — a file selected on an OS or an architecture is +> absent on some machines by design. +{{% /notice %}} + +**3. Add the hand-written tests that examples cannot express.** + +`Examples:` still drives the generated tests for all variants. Anything they cannot reach goes +in `internal/assertions/_go1NN_test.go`, carrying the same `//go:build` line. For +`ErrorAsType`, `error_go126_test.go` covered the nil-target form, where `E` cannot be inferred +from the argument and has to be written out: `ErrorAsType[*customError](mock, err, nil)`. + +**4. Regenerate.** + +```bash +go generate ./... +``` + +No change to the generator itself. Each category of generated file gains a guarded twin, +suffixed with the constraint (`model.GoBuildTag` turns `go1.26` into `go126`): + +| Default file | Guarded twin | +|--------------|--------------| +| `assert/assert_assertions.go` | `assert/assert_assertions_go126.go` | +| `assert/assert_format.go` | `assert/assert_format_go126.go` | +| `assert/assert_assertions_test.go` | `assert/assert_assertions_go126_test.go` | +| `assert/assert_format_test.go` | `assert/assert_format_go126_test.go` | +| `assert/assert_examples_test.go` | `assert/assert_examples_go126_test.go` | + +`require/` gets the same five. Package boilerplate is not duplicated: the `Assertions` type, +`New`, `forwardArgs`, the mock types and the shared test fixtures stay in the default files, +gated in the templates behind `{{ if not .BuildConstraint }}`, and the guarded files refer to +them. A category with nothing to declare produces no file at all. + +**The forward methods are keyed on a second constraint.** A generic assertion becomes a method +only from go1.27 on, whatever guard its source carries, so `ErrorAsType`'s methods went to +`assert_forward_go127.go` — `Function.ForwardGoBuild` returns the higher of the source guard +and go1.27 for a generic function, and `Generate` partitions the forward files on that instead +of on `GoBuild`. This is why the go1.26 batch above has no `assert_forward_go126.go`. + +On the doc site the badges are automatic. The domain page prints +{{% goversion "go1.26" %}} next to the assertion heading and adds a line to the legend, and +`metrics.md` badges the row. Nothing to hand-edit. + +**5. Removing the gate, later.** + +When the supported floor catches up, move the functions into the plain domain file, delete the +two `//go:build` lines, fold the hand-written tests back into `_test.go`, and +regenerate. `sweepOrphanVariants` deletes the ten `*_go126*.go` files this run no longer +produces, logging each removal; it only touches files matching `_*_go[_test].go` that +carry our "DO NOT EDIT" marker, so a hand-authored file of the same shape survives. + +> `go generate ./...` snapshots each package's file list before running the directives, so the +> run that removes an orphan can end on a harmless "no such file". Rerun it, or call +> `go run ./codegen/main.go` directly. + +Also re-enable `TestBuildConstraintDetection` in `codegen/internal/scanner/buildtags_test.go` +when a guard comes back, and skip it again when the last one goes: it asserts that the scanner +attaches the constraint to the guarded function, and needs a guarded assertion to look at. + +--- + +### Regenerating + +Build the generator and run it from `codegen/`. Every flag defaults to what this repository +needs — `-work-dir ..` to scan, `-target-root ..` to write — so the bare command is the whole +procedure: + +```bash +cd codegen +go build -o codegen . +./codegen +``` + +`go generate ./...` from the repository root does the same through the directive in `doc.go`. +It is the shorter form, and the one to avoid when a run deletes a generated file: `go generate` +snapshots each package's file list before it runs the directives, so it can end on a spurious +"no such file" for the file just removed (see [Removing the gate](#adding-an-assertion-with-go-version-gate)). + +**Which Go runs the scan matters.** The generator reads `internal/assertions` through +`packages.Load`, which shells out to `go list`; that subprocess uses the `go` on `PATH`, in the +repository root, with the workspace active. The toolchain that compiled the generator has no +say in it. This only bites when `internal/assertions` carries a `//go:build go1.N` file — see +[Adding an assertion with go version gate](#adding-an-assertion-with-go-version-gate) — and the +fix is to name the toolchain on the command: `GOTOOLCHAIN=go1.N ./codegen`. + +Then check the result: + +```bash +git diff --stat # generated files only; nothing under internal/assertions +go test ./... +``` + +--- + ### Generator Flags +> Defaults are defined so running the command is essentially hassle-free to maintain this project: no arguments needed. + ```bash go run ./codegen/main.go \ -work-dir=.. \ -input-package=github.com/go-openapi/testify/v2/internal/assertions \ -output-packages=assert,require \ -target-root=.. \ + -target-doc=docs/doc-site/api \ -include-format-funcs=true \ -include-forward-funcs=true \ -include-tests=true \ + -include-generics=true \ + -include-helpers=true \ -include-examples=true \ -runnable-examples=true \ - -include-helpers=true \ - -include-generics=false + -include-doc=true ``` Current usage with `go generate` (see `doc.go`): @@ -274,7 +451,7 @@ Current usage with `go generate` (see `doc.go`): //go:generate go run ./codegen/main.go -target-root . -work-dir . ``` -**Note:** Generic functions are planned but not yet implemented. +Pass `-include-doc=false` to regenerate the code alone and leave `docs/doc-site/api` untouched. ### Verification diff --git a/docs/doc-site/project/maintainers/ROADMAP.md b/docs/doc-site/project/maintainers/ROADMAP.md index d854582c4..e577e89be 100644 --- a/docs/doc-site/project/maintainers/ROADMAP.md +++ b/docs/doc-site/project/maintainers/ROADMAP.md @@ -50,12 +50,14 @@ timeline : generic assertions as forward methods (go1.27+) : ErrorNotContains ⏳ v2.8 (September 2026) : - : go1.26+ required + : ⏳ Must helper + : ⏳ Assertion with options + : ✅ go1.26+ required ⏳ v2.9 (December 2026) : (tentative) : revive test suites {{< /mermaid >}} -## Dropped enveavors +## Dropped endeavors For the moment, and after some research, we punt on the following features. We might reconsider these choices in the future, but for now, we are unsure about whether they are worth the added complexity. diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index 3c0c69005..d33a3f3cf 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -540,7 +540,7 @@ See [Examples](./EXAMPLES.md#goroutine-leak-detection) for usage patterns. #### ⚠️ Behavior Changes -**Architecture change**: YAML support is now opt-in via `import _ "github.com/go-openapi/testify/v2/enable/yaml"` +**Architecture change**: YAML support is now opt-in via `import _ "github.com/go-openapi/testify/enable/yaml/v2"` **Behavior changes**: None diff --git a/docs/doc-site/usage/EXAMPLES.md b/docs/doc-site/usage/EXAMPLES.md index 954198ba8..db298566a 100644 --- a/docs/doc-site/usage/EXAMPLES.md +++ b/docs/doc-site/usage/EXAMPLES.md @@ -6,6 +6,8 @@ weight: 2 {{% notice primary "TL;DR" "meteor" %}} > If you've already used `github.com/stretchr/testify`, adopting v2 will be straightforward. +> +> The API remains largely the same, just more systematic and clarified. {{% /notice %}} More examples to showcase generic assertions specifically may be found [here](./GENERICS.md). @@ -131,6 +133,7 @@ func TestCollections(t *testing.T) { ```go import ( + "io/fs" "testing" "github.com/go-openapi/testify/v2/assert" @@ -154,6 +157,9 @@ func TestErrors(t *testing.T) { // Check error type with errors.Is assert.ErrorIs(t, err, ErrDivisionByZero) + + // Check error type with errors.AsType (without capturing the target error) + assert.ErrorAsType[*fs.PathError](t, err, nil) } ``` @@ -205,6 +211,8 @@ Testify provides multiple ways to call assertions: ### 1. Package-Level Functions +Simple. Direct. + ```go import ( "testing" @@ -221,6 +229,8 @@ func TestPackageLevel(t *testing.T) { ### 2. Formatted Variants (Custom Messages) +Be more explicit about why a test failed. + ```go import ( "testing" @@ -238,6 +248,8 @@ func TestFormatted(t *testing.T) { ### 3. Forward Methods (Cleaner Syntax) +Most concise for long sequences of assertions: avoid the constant reinjection of `t *testing.T`. + ```go import ( "testing" @@ -261,6 +273,8 @@ func TestForward(t *testing.T) { ### 4. Forward Methods with Formatting +The above, combined. + ```go import ( "testing" @@ -282,7 +296,7 @@ func TestForwardFormatted(t *testing.T) { ## Table-Driven Tests -The idiomatic Go pattern for testing multiple cases should be: +The idiomatic Go pattern for testing multiple cases should look something like: ```go import ( @@ -305,7 +319,7 @@ func TestAdd(t *testing.T) { }) for tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { // the name of the testcase so a failure is easily found result := Add(tt.a, tt.b) assert.Equal(t, tt.expected, result) }) @@ -313,7 +327,11 @@ func TestAdd(t *testing.T) { } ``` -With forward methods for cleaner syntax: +> We also like to combine this with iterators like [this](../tutorial/index.html#table-driven-tests-with-iterator-pattern): +> the logic of the test is not polluted by the list of test cases. +> Testcase iterators may also be parameterized. + +You may use forward methods for a more concise syntax: ```go func TestAdd(t *testing.T) { @@ -388,8 +406,14 @@ func TestJSONResponse(t *testing.T) { } ``` +> The same assertions are available for YAML. Don't forget to enable +> YAML by importing `github.com/go-openapi/testify/enable/yaml/v2`. + ### Testing with Subtests +Each subtest documents a few assertions and shows as such in the test report. +Prefer an explicit named subtest over a comment in code. + ```go import ( "testing" @@ -441,6 +465,9 @@ func TestPanics(t *testing.T) { assert.PanicsWithValue(t, "division by zero", func() { Divide(10, 0) }) + + // Function should panic with a specific error message + assert.PanicsWithError(t, "crazy error", func(){ GoCrazy() }) } ``` diff --git a/docs/doc-site/usage/MIGRATION.md b/docs/doc-site/usage/MIGRATION.md index f3aee8cc5..8e3369b3c 100644 --- a/docs/doc-site/usage/MIGRATION.md +++ b/docs/doc-site/usage/MIGRATION.md @@ -51,7 +51,7 @@ This pass handles: - Import path rewriting (`assert`, `require`, root package) - Function renames (`EventuallyWithT` to `EventuallyWith`, `NoDirExists` to `DirNotExists`, etc.) - Type replacement (`PanicTestFunc` to `func()`) -- YAML enable import injection (adds `_ "github.com/go-openapi/testify/v2/enable/yaml"` when `YAMLEq` is used) +- YAML enable import injection (adds `_ "github.com/go-openapi/testify/enable/yaml/v2"` when `YAMLEq` is used) - Incompatible import detection (`mock`, `suite`, `http` packages emit warnings with guidance) - `go.mod` update (drops `stretchr/testify`, adds `go-openapi/testify/v2`) diff --git a/docs/doc-site/usage/TUTORIAL.md b/docs/doc-site/usage/TUTORIAL.md index eb3209a6e..cdabcdce6 100644 --- a/docs/doc-site/usage/TUTORIAL.md +++ b/docs/doc-site/usage/TUTORIAL.md @@ -11,6 +11,10 @@ weight: 3 ## What makes a good test? +{{% notice primary "RFC" "meteor" %}} +> This page holds quite a few opinions. Feel free to share your experience and thoughts and help contribute to this page! +{{% /notice %}} + A good test is: 1. **Focused** - Tests one logical concept @@ -104,10 +108,15 @@ func TestUserCreation(t *testing.T) { ### Table-Driven Tests with Iterator Pattern -The **iterator pattern** is the idiomatic way to write table-driven tests in Go 1.23+. This repository uses it extensively, and you should too. +The **iterator pattern** is a great and idiomatic way to write table-driven tests in Go 1.23+. +This repository uses it extensively, and we think you should too. #### Why Table-Driven Tests? +This separates the (repeated) logic a test from the test cases, making it easier to add or modify test cases. + +Each test case may be run in parallel. Typically, each subtest in the test loop is independent. + Instead of writing separate test functions for each case: ```go @@ -139,12 +148,15 @@ Write one test function with multiple cases: ```go // ✅ Better - all cases in one place func TestAdd(t *testing.T) { + t.Parallel() + // All test cases defined once // Test logic written once // Easy to add new cases + for c := range addTestCases() { t.Run(c.name, func(t *testing.T) { - t.Parallel() + t.Parallel() // each iteration runs concurrently result := Add(c.a, c.b) assert.Equal(t, c.expected, result) @@ -159,6 +171,12 @@ func addTestCases() iter.Seq[addTestCase] { #### The Iterator Pattern +It values test cases as the main asset of your tests: by promoting testcases to their own +function and type, they become reusable and parameterizable. + +In this project, we leverage testcase reusability a lot, for instance to make sure that both generic and non-generic assertions +are subject to the same tests. + **Structure:** ```go @@ -172,7 +190,7 @@ import ( // 1. Define a test case struct type addTestCase struct { - name string + name string // the test case name is documented in the test execution logs and identifiable when failing a, b int expected int } @@ -224,6 +242,9 @@ func TestAdd(t *testing.T) { #### Why This Pattern Is Better +This is an opinionated pattern, based on our own experience with maintaining tens of thousands of tests. +You might hold a different opinion. Here are the reasons that support the proposed approach. + **Clean separation of concerns:** - Test data (in iterator function) separate from test logic (in test function) - Easy to see all test cases at a glance @@ -246,9 +267,14 @@ func TestAdd(t *testing.T) { - Adding a case: just append to the slice - Changing test logic: edit one place - Renaming fields: IDE refactoring works +- Test cases can be reused, composed, parameterized #### Comparison with Traditional Pattern +The proposed pattern is slightly more verbose than the inlined pattern, +but this is largely offset by the improved readability as soon as you get a few test cases. +When your test logic gets more complex, the reader's focus is on what runs. + **Traditional inline pattern:** ```go @@ -398,9 +424,9 @@ func userValidationCases() iter.Seq[userValidationCase] { --- -### Using testify with Iterator Pattern +### Using testify with an Iterator Pattern -The iterator pattern works beautifully with testify's forward methods: +The iterator pattern works beautifully with testify's forward methods: concise, yet accurate. ```go import ( @@ -501,7 +527,7 @@ func TestAdd(t *testing.T) { ### Setup and Teardown -Use `defer` for cleanup: +Use `t.Cleanup` for cleanup: ```go func TestDatabaseOperations(t *testing.T) { @@ -530,7 +556,9 @@ func TestDatabaseOperations(t *testing.T) { ### Edge Cases to Test -Always include these test categories: +Remember to include limit cases to your tests. Always. + +Besides the happy path, include these test categories: #### 1. Empty/Zero Values @@ -586,7 +614,7 @@ Always include these test categories: ### Testing Errors -**Bad practice - checking error string:** +**Bad practice - checking error string leads to extra test maintenance:** ```go // ❌ Fragile - breaks if error message changes @@ -595,7 +623,7 @@ if err == nil || err.Error() != "division by zero" { } ``` -**Good practice - checking error chain:** +**Better - checking error chain:** ```go // ✅ Semantic error checking @@ -627,6 +655,26 @@ import ( "github.com/go-openapi/testify/v2/require" ) +func TestDivide(t *testing.T) { + t.Parallel() + + for c := range divideTestCases() { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + result, err := Divide(c.a, c.b) + + if c.shouldErr { + assert.Error(t, err) + assert.ErrorIs(t, err, ErrDivisionByZero) + } else { + require.NoError(t, err) + assert.Equal(t, c.expected, result) + } + }) + } +} + type divideTestCase struct { name string a, b float64 @@ -665,26 +713,6 @@ func divideTestCases() iter.Seq[divideTestCase] { }, }) } - -func TestDivide(t *testing.T) { - t.Parallel() - - for c := range divideTestCases() { - t.Run(c.name, func(t *testing.T) { - t.Parallel() - - result, err := Divide(c.a, c.b) - - if c.shouldErr { - assert.Error(t, err) - assert.ErrorIs(t, err, ErrDivisionByZero) - } else { - require.NoError(t, err) - assert.Equal(t, c.expected, result) - } - }) - } -} ``` --- diff --git a/docs/doc-site/usage/USAGE.md b/docs/doc-site/usage/USAGE.md index dba032223..50f32f970 100644 --- a/docs/doc-site/usage/USAGE.md +++ b/docs/doc-site/usage/USAGE.md @@ -5,11 +5,15 @@ weight: 1 --- {{% notice primary "TL;DR" "meteor" %}} -> Learn testify's naming conventions (assert vs require, format variants, generic `T` suffix), argument order patterns, and how to navigate -> {{% siteparam "metrics.assertions" %}} assertions organized into {{% siteparam "metrics.domains" %}} domains. Start here to understand the API structure. +> Learn testify's naming conventions (assert vs require, format variants, generic `T` suffix), argument order patterns, +> and how to navigate the {{% siteparam "metrics.assertions" %}} assertions organized into {{% siteparam "metrics.domains" %}} domains. +> +> Start here to understand the API structure. {{% /notice %}} -Testify v2 provides **{{% siteparam "metrics.functions" %}} functions** ({{% siteparam "metrics.assertions" %}} assertions including {{% siteparam "metrics.generics" %}} generic variants, plus {{% siteparam "metrics.helpers" %}} helper functions) organized into {{% siteparam "metrics.domains" %}} domains. This guide explains how to navigate the API and use the naming conventions effectively. +Testify v2 provides **{{% siteparam "metrics.functions" %}} functions** ({{% siteparam "metrics.assertions" %}} assertions including {{% siteparam "metrics.generics" %}} generic variants, plus {{% siteparam "metrics.helpers" %}} helper functions) organized into {{% siteparam "metrics.domains" %}} domains. + +This guide explains how to navigate the API and use the naming conventions effectively. ## How the API is Organized @@ -17,10 +21,11 @@ Assertions are grouped by domain for easier discovery: | Domain | Examples | Count | |--------|----------|-------| +| **Async** | `Eventually`, `Never` | {{% siteparam "metrics.by_domain.async.count" %}} | | **Boolean** | `True`, `False` | {{% siteparam "metrics.by_domain.boolean.count" %}} | | **Collection** | `Contains`, `Len`, `Empty`, `ElementsMatch` | {{% siteparam "metrics.by_domain.collection.count" %}} | | **Comparison** | `Greater`, `Less`, `Positive` | {{% siteparam "metrics.by_domain.comparison.count" %}} | -| **Condition** | `Eventually`, `Never`, `Consistently` | {{% siteparam "metrics.by_domain.condition.count" %}} | +| **Condition** | `Condition`, `Blocked` | {{% siteparam "metrics.by_domain.condition.count" %}} | | **Equality** | `Equal`, `NotEqual`, `EqualValues`, `Same`, `Exactly` | {{% siteparam "metrics.by_domain.equality.count" %}} | | **Error** | `Error`, `NoError`, `ErrorIs`, `ErrorAs`, `ErrorContains` | {{% siteparam "metrics.by_domain.error.count" %}} | | **File** | `FileExists`, `DirExists`, `FileEmpty` | {{% siteparam "metrics.by_domain.file.count" %}} | @@ -44,10 +49,11 @@ See the complete [API Reference](../api/_index.md) organized by domain for a det ### Quick Reference +- **[Quick index](../api/metrics.md)** - All in a single table, with semantic opposites side by side - **[Examples](./EXAMPLES.md)** - Practical code examples for common testing scenarios - **[API Reference](../api/_index.md)** - Complete assertion catalog organized by domain -- **[Generics Guide](../GENERICS.md)** - Using type-safe assertions with the `T` suffix -- **[Changes](../CHANGES.md)** - All changes since fork from stretchr/testify +- **[Generics Guide](./GENERICS.md)** - Using type-safe assertions with the `T` suffix +- **[Changes](./CHANGES.md)** - All changes since fork from stretchr/testify - **[pkg.go.dev](https://pkg.go.dev/github.com/go-openapi/testify/v2)** - godoc API reference with full signatures ### Finding the Right Assertion @@ -158,7 +164,7 @@ Most assertions come with their opposite variant, typically formed by adding a ` These exceptions follow natural English usage: - Testing for `False` is clearer than testing for "not true" - (strictly) `Negative` numbers are semantically opposite to (strictly) `Positive`, unless when `Zero`, and not "not positive" -- `Less` is the natural opposite of `Greater` in comparisons +- (strictly) `Less` is the natural opposite of `GreaterOrEqual` in comparisons {{% /notice %}} **More semantic opposites:** @@ -196,6 +202,10 @@ assert.WithinDuration(t, expected, actual, delta) assert.Implements(t, (*interface)(nil), object) // Expected interface, actual object ``` +> What if I am wrong with the order? +> +> Most tests would still pass, but the error message will be misleading when they don't. + #### Comparison Operators: e1, e2 Comparison assertions express the relationship directly (reads as "assert e1 > e2"): diff --git a/go.work b/go.work index 54e5f6374..a84af3272 100644 --- a/go.work +++ b/go.work @@ -9,13 +9,13 @@ use ( go 1.26.0 -// Dev/codegen toolchain floor. Must be >= the highest //go:build go1.N guard in play: -// the guards written by hand in internal/assertions (enforced by TestToolchainFloorCoversGuards), -// and the go1.27 guard codegen stamps on the forward methods of the generic assertions. -// Below that floor, guarded files are dropped before the compiler sees them: codegen would -// emit incomplete output, and `go test ./...` would exercise none of the generic methods. +// No `toolchain` line on purpose. It applies to every command run in the workspace, +// including CI's `go test work ./...`, which runs the oldstable/stable matrix. Pinning it +// makes both matrix entries build the same toolchain, and the older one then stops compiling +// the packages without their //go:build go1.N files. // -// This line is NOT published to consumers: a dependency's toolchain directive is ignored -// downstream — only our go.mod `go 1.26.0` floor reaches them, so go1.26 users still build -// (guarded files excluded). -toolchain go1.27.0 +// The toolchain a regeneration needs is named on the command instead: +// +// cd codegen && go build -o codegen . && GOTOOLCHAIN=go1.N ./codegen +// +// See docs/doc-site/project/maintainers/CODEGEN.md ("Regenerating"). diff --git a/hack/doc-site/hugo/metrics.yaml b/hack/doc-site/hugo/metrics.yaml index cc67afc90..ab0bb6989 100644 --- a/hack/doc-site/hugo/metrics.yaml +++ b/hack/doc-site/hugo/metrics.yaml @@ -1,6 +1,6 @@ params: metrics: - domains: 19 + domains: 20 functions: 144 assertions: 140 generics: 55 @@ -8,6 +8,9 @@ params: helpers: 4 others: 0 by_domain: + async: + name: Async + count: 4 boolean: name: Boolean count: 4 @@ -22,7 +25,7 @@ params: count: 12 condition: name: Condition - count: 9 + count: 5 equality: name: Equality count: 16 diff --git a/internal/assertions/async.go b/internal/assertions/async.go new file mode 100644 index 000000000..754d42c1d --- /dev/null +++ b/internal/assertions/async.go @@ -0,0 +1,916 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package assertions + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "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, at most until timeout, +// or until the parent context of the test is cancelled. +// +// 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] +// which is cancelled on test failure. +// +// # Usage +// +// 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 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. +// +// # Panic recovery +// +// If the condition panics, the panic is recovered and treated as a failed tick +// (equivalent to returning false or a non-nil error). For [Eventually], this means +// the poller retries on the next tick — if a later tick succeeds, the assertion +// succeeds. For [Never] and [Consistently], a panic is treated as the condition +// erroring, which causes immediate failure. +// +// The recovered panic is wrapped as an error with the sentinel [errConditionPanicked], +// detectable with [errors.Is]. +// +// # Attention point +// +// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce +// 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). +// +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires `*testing.T`). +// +// # 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[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + // Domain: async + // Opposite: Never + if h, ok := t.(H); ok { + h.Helper() + } + + return eventually(t, condition, timeout, tick, msgAndArgs...) +} + +// 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] ("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. +// +// # Usage +// +// assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) +// +// See also [Eventually] for details about using context, concurrency, and panic recovery. +// +// # Alternative condition signature +// +// The simplest form of condition is: +// +// func() bool +// +// Use [Consistently] instead if you want to use a condition returning an error. +// +// # Panic recovery +// +// A panicking condition is treated as an error, causing [Never] to fail immediately. +// See [Eventually] for details. +// +// # Concurrency +// +// See [Eventually]. +// +// # Attention point +// +// See [Eventually]. +// +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] to run the polling loop inside a +// [testing/synctest] bubble, which uses a fake clock. This eliminates +// timing-induced flakiness and makes the tick count deterministic. See +// [WithSynctest] for the constraints (no real I/O in the condition, +// requires [*testing.T]). Note: [Never] does not accept the context/error +// form of condition, so [WithSynctestContext] does not apply here. +// +// # 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[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + // Domain: async + 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, concurrency, and panic recovery. +// +// # 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. +// +// # Panic recovery +// +// A panicking condition is treated as an error, causing [Consistently] to fail immediately. +// See [Eventually] for details. +// +// # Concurrency +// +// See [Eventually]. +// +// # Attention point +// +// See [Eventually]. +// +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run +// the polling loop inside a [testing/synctest] bubble, which uses a fake +// clock. This eliminates timing-induced flakiness and makes the tick count +// deterministic. See [WithSynctest] for the constraints (no real I/O in +// the condition, requires [*testing.T]). +// +// # 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: async + if h, ok := t.(H); ok { + h.Helper() + } + + return consistently(t, condition, timeout, tick, msgAndArgs...) +} + +// 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] +// to accumulate errors from calling other assertions. +// +// 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 the timeout, the collected errors from the +// last tick are copied to t. +// +// Calling [CollectT.FailNow] (directly, or transitively through [require] assertions) +// fails the current tick only: the poller will retry on the next tick. This means +// [require]-style assertions inside [EventuallyWith] behave naturally — they abort +// the current evaluation and let the polling loop converge. +// +// To abort the whole assertion immediately (e.g. when the condition can no longer +// be expected to succeed), call [CollectT.Cancel]. +// +// # Usage +// +// externalValue := false +// go func() { +// time.Sleep(8*time.Second) +// externalValue = true +// }() +// +// assertions.EventuallyWith(t, func(c *assertions.CollectT) { +// // add assertions as needed; any assertion failure will fail the current tick +// assertions.True(c, externalValue, "expected 'externalValue' to be true") +// }, +// 10*time.Second, +// 1*time.Second, +// "external state has not changed to 'true'; still false", +// ) +// +// # 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 is wrapped in its own goroutine, so a call to [runtime.Goexit] +// (e.g. via [require] assertions or [CollectT.FailNow]) cleanly aborts only the +// current tick. +// +// # Panic recovery +// +// If the condition panics, the panic is recovered and recorded as an error in the +// [CollectT] for that tick. The poller treats it as a failed tick and retries on the +// next one. If the assertion times out, the panic error is included in the collected +// errors reported on the parent t. +// +// See [Eventually] for the general panic recovery semantics. +// +// # Synctest (opt-in) +// +// Wrap the condition with [WithSynctestCollect] (or [WithSynctestCollectContext]) +// to run the polling loop inside a [testing/synctest] bubble, which uses +// a fake clock. This eliminates timing-induced flakiness and makes the +// tick count deterministic. See [WithSynctest] for the constraints (no +// real I/O in the condition, requires [*testing.T]). +// +// # Examples +// +// 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 +// failure: func(c *CollectT) { c.Cancel() }, 100*time.Millisecond, 20*time.Millisecond +func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + // Domain: async + if h, ok := t.(H); ok { + h.Helper() + } + + return eventuallyWithT(t, condition, timeout, tick, msgAndArgs...) +} + +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() + } + + wantsBubble, cond := makeCondition(condition, false) + p := newConditionPoller(pollOptions{ + mode: pollUntilTrue, + failMessage: "condition never satisfied", + }) + + return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) +} + +func never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + + wantsBubble, cond := makeCondition(condition, true) + p := newConditionPoller(pollOptions{ + mode: pollUntilTimeout, + failMessage: "condition satisfied", + }) + + return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) +} + +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() + } + + wantsBubble, cond := makeCondition(condition, false) + p := newConditionPoller(pollOptions{ + mode: pollUntilTimeout, + failMessage: "condition failed once", + }) + + return runPoller(t, p, cond, timeout, tick, wantsBubble, 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 + wantsBubble, fn := makeCollectibleCondition(collectCondition) + + condition := func(ctx context.Context) (err error) { + collector := new(CollectT).withCancelFunc(cancelFunc) + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%w: %v", errConditionPanicked, r) + collector.errors = append(collector.errors, err) + } + if collector.failed() { + lastCollectedErrors = collector.collected() + err = collector.last() + } + }() + + fn(ctx, collector) + + return nil + } + + copyCollected := func(tt T) { + for _, err := range lastCollectedErrors { + tt.Errorf("%v", err) + } + } + + p := newConditionPoller(pollOptions{ + mode: pollUntilTrue, + failMessage: "condition never satisfied", + onFailure: copyCollected, + onSetup: func(cancel func()) { cancelFunc = cancel }, + }) + + return runPoller(t, p, condition, timeout, tick, wantsBubble, msgAndArgs...) +} + +// runPoller dispatches the polling to either the real-time or the +// [synctest] bubble-wrapped path, based on whether the condition opted into +// fake time AND the caller passed a concrete [*testing.T]. +// +// When `wantsBubble` is true but `t` is not a `*testing.T` (e.g. a mock or +// [CollectT]), the call silently falls back to real-time polling. The +// synctest bubble requires a real `*testing.T`. +func runPoller(t T, p *conditionPoller, cond func(context.Context) error, timeout, tick time.Duration, wantsBubble bool, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + + testingT, canBubble := t.(*testing.T) + if !wantsBubble || !canBubble { + return p.pollCondition(t, cond, timeout, tick, msgAndArgs...) + } + + var result bool + synctest.Test(testingT, func(inner *testing.T) { + result = p.pollCondition(inner, cond, timeout, tick, msgAndArgs...) + }) + + return result +} + +// makeCondition normalizes any variant from [Conditioner] or [NeverConditioner] +// into the unified `func(context.Context) error` form used by [pollCondition], +// and reports whether the caller opted into synctest-bubble polling. +// +// [WithSynctest] and [WithSynctestContext] are recognized as their underlying +// `func() bool` and `func(context.Context) error` forms with `wantsBubble = true`. +func makeCondition(condition any, reverse bool) (wantsBubble bool, cond func(context.Context) error) { + switch typed := condition.(type) { + case WithSynctest: + _, cond = makeCondition((func() bool)(typed), reverse) + return true, cond + case WithSynctestContext: + _, cond = makeCondition((func(context.Context) error)(typed), reverse) + return true, cond + case func() bool: + if !reverse { + return false, 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 false, 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 false, typed + default: // unreachable + panic(fmt.Errorf("unsupported Conditioner type. Mismatch with type constraint: %T", condition)) + } +} + +// makeCollectibleCondition normalizes any [CollectibleConditioner] variant +// into the unified `func(context.Context, *CollectT)` form, and reports +// whether the caller opted into synctest-bubble polling. +func makeCollectibleCondition(condition any) (wantsBubble bool, fn func(context.Context, *CollectT)) { + switch typed := condition.(type) { + case WithSynctestCollect: + _, fn = makeCollectibleCondition((func(*CollectT))(typed)) + return true, fn + case WithSynctestCollectContext: + _, fn = makeCollectibleCondition((func(context.Context, *CollectT))(typed)) + return true, fn + case func(*CollectT): + return false, func(ctx context.Context, collector *CollectT) { + select { + case <-ctx.Done(): + collector.Errorf("%v", ctx.Err()) + default: + typed(collector) + } + } + case func(context.Context, *CollectT): + return false, typed + default: // unreachable + panic(fmt.Errorf("unsupported CollectibleConditioner type. Mismatch with type constraint: %T", condition)) + } +} + +func recoverCondition(fn func(context.Context) error) func(context.Context) error { + return func(ctx context.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%w: %v", errConditionPanicked, r) + } + }() + + return fn(ctx) + } +} + +type conditionPoller struct { + pollOptions + + ticker *time.Ticker + reported atomic.Bool + conditionChan chan func(context.Context) error + doneChan chan struct{} +} + +func newConditionPoller(o pollOptions) *conditionPoller { + return &conditionPoller{ + pollOptions: o, + } +} + +// initChannels creates the polling channels. MUST be called from inside +// [pollCondition] so that — when the caller activated a [synctest] bubble +// — the channels are bubble-owned. Receives on channels created outside +// the bubble do NOT count as durably blocking, which stalls the fake clock. +func (p *conditionPoller) initChannels() { + p.conditionChan = make(chan func(context.Context) error, 1) + p.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/Consistently). + pollUntilTimeout +) + +// pollOptions configures the condition polling behavior. +type pollOptions struct { + mode pollMode + failMessage string // error message added at the end of the stack + onFailure func(t T) // called on failure (e.g., to copy collected errors) + onSetup func(cancel func()) // called after context setup to expose cancel function +} + +// 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(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, timeout) + defer cancel() + + failFunc := p.failFunc(t, msgAndArgs...) + + // Allow caller to capture the cancel function (for eventuallyWithT's CollectT) + if p.onSetup != nil { + p.onSetup(cancel) + } + + condition = recoverCondition(condition) + + // Channels and ticker MUST be created inside pollCondition so that, + // when the caller activated a synctest bubble, they are bubble-owned + // primitives. Channels created outside the bubble do not count as + // durably blocking and would stall the fake clock. + p.initChannels() + + p.ticker = time.NewTicker(tick) + defer p.ticker.Stop() + + // Check the condition once first on the initial call. + p.conditionChan <- condition + + var wg sync.WaitGroup + + // Goroutine 1: Poll for the condition at every tick + wg.Add(1) + go p.pollAtTickFunc(parentCtx, ctx, condition, failFunc, &wg)() + + // Goroutine 2: Execute the condition and check results + wg.Add(1) + go p.executeCondition(parentCtx, ctx, failFunc, &wg)() + + wg.Wait() + + // Determine success based on mode + return p.determineOutcome(parentCtx, ctx, failFunc, t)() +} + +func (p *conditionPoller) failFunc(t T, msgAndArgs ...any) func(string) { + return func(reason string) { + if p.reported.CompareAndSwap(false, true) { + if reason != "" { + t.Errorf("%s", reason) + } + Fail(t, p.failMessage, msgAndArgs...) + } + } +} + +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() { + defer wg.Done() + + for { + select { + case <-parentCtx.Done(): + failFunc(parentCtx.Err().Error()) + return + case <-ctx.Done(): + return // timeout reached = success for Never + case <-p.doneChan: + return + case <-p.ticker.C: + // Nested select prevents blocking on channel send if context was cancelled + // between receiving the tick and attempting to send the condition. + select { + case <-parentCtx.Done(): + failFunc(parentCtx.Err().Error()) + return + case <-ctx.Done(): + return + case <-p.doneChan: + return + case p.conditionChan <- condition: + } + } + } + } + } + + // For Eventually: parent cancellation flows through ctx + return func() { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + failFunc(ctx.Err().Error()) + return + case <-p.doneChan: + return + case <-p.ticker.C: + // Nested select prevents blocking on channel send if context was cancelled + // between receiving the tick and attempting to send the condition. + select { + case <-ctx.Done(): + failFunc(ctx.Err().Error()) + return + case <-p.doneChan: + return + case p.conditionChan <- condition: + } + } + } + } +} + +func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failFunc func(string), wg *sync.WaitGroup) func() { + if p.mode == pollUntilTimeout { + // For Never and Consistently + return func() { + defer wg.Done() + + for { + select { + case <-parentCtx.Done(): + failFunc(parentCtx.Err().Error()) + return + case <-ctx.Done(): + return // timeout = success + case fn := <-p.conditionChan: + var conditionWg sync.WaitGroup + conditionWg.Go(func() { // guards against the condition issue an early GoExit + + if err := fn(ctx); err != nil { + close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently + } + }) + conditionWg.Wait() + + select { + case <-p.doneChan: // done: early exit + return + default: + } + } + } + } + } + + // For Eventually + return func() { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + failFunc(ctx.Err().Error()) + return + case fn := <-p.conditionChan: + var conditionWg sync.WaitGroup + conditionWg.Go(func() { // guards against the condition issue an early GoExit + + if err := fn(ctx); err == nil { + close(p.doneChan) // (condition true <=> err == nil) = success for Eventually + } + }) + conditionWg.Wait() + + select { + case <-p.doneChan: // done: early exit + return + default: + } + } + } + } +} + +func (p *conditionPoller) determineOutcome(parentCtx, ctx context.Context, failFunc func(string), t T) func() bool { + if p.mode == pollUntilTimeout { + return func() bool { + select { + case <-p.doneChan: + // For Never: doneChan closed means condition became true + // But if timeout was reached first (ctx.Err != nil), it's still a success. + // This handles the race between timeout and condition becoming true. + if ctx.Err() != nil { + return true + } + // Condition became true before timeout = failure + failFunc("") + return false + default: + // doneChan not closed + // For Never: timeout reached without condition being true = success + // We should return a success, unless the parent context has failed. + return parentCtx.Err() == nil + } + } + } + + return func() bool { + select { + case <-p.doneChan: + // For Eventually: doneChan closed means condition became true + if ctx.Err() != nil { + // Timeout occurred before or during success + if p.onFailure != nil { + p.onFailure(t) + } + return false + } + return true + default: + // doneChan not closed + // opts.mode = pollUntilTrue + // For Eventually: should not reach here (failFunc already called) + if p.onFailure != nil { + p.onFailure(t) + } + + return false + } + } +} + +func (p *conditionPoller) parentContextFromT(t T) context.Context { + var parentCtx context.Context + if withContext, ok := t.(contextualizer); ok { + parentCtx = withContext.Context() + } + if parentCtx == nil { + parentCtx = context.Background() + } + + return parentCtx +} + +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), timeout) + } else { + ctx, cancel = context.WithTimeout(parentCtx, timeout) + } + + return ctx, cancel +} + +// Sentinel errors recorded by async condition assertions. +// Kept package-private: callers should rely on observable behavior, not on +// the marker shape. They are distinguishable so future tooling can tell apart +// "tick aborted by require", "user explicitly cancelled the assertion", +// and "condition panicked". +var ( + errFailNow = errors.New("collect: failed now (tick aborted)") + errCancelled = errors.New("collect: cancelled (assertion aborted)") + errConditionPanicked = errors.New("condition panicked") +) + +// CollectT implements the [T] interface and collects all errors. +// +// [CollectT] is specifically intended to be used with [EventuallyWith] and +// should not be used outside of that context. +type CollectT struct { + // Domain: async + // + // Maintainer: + // 1. FailNow() exits the current tick goroutine via runtime.Goexit (matching + // stretchr/testify semantics): require-style assertions abort the current + // evaluation and the poller retries on the next tick. It does NOT cancel + // the EventuallyWith context. + // 2. Cancel() is the explicit escape hatch: it cancels the EventuallyWith + // context before exiting via runtime.Goexit, aborting the whole assertion. + // 3. We no longer establish the distinction between c.errors nil or empty. + // Non-empty is an error, full stop. + // 4. Deprecated methods have been removed. + + // A slice of errors. Non-empty slice denotes a failure. + errors []error + + // cancelContext cancels the parent EventuallyWith context on Cancel(). + cancelContext func() +} + +// Helper is like [testing.T.Helper] but does nothing. +func (*CollectT) Helper() {} + +// Errorf collects the error. +func (c *CollectT) Errorf(format string, args ...any) { + c.errors = append(c.errors, fmt.Errorf(format, args...)) +} + +// FailNow records a failure for the current tick and exits the condition +// goroutine via [runtime.Goexit]. +// +// It does NOT cancel the [EventuallyWith] context: the poller will retry on +// the next tick. If a later tick succeeds, the assertion succeeds. If no tick +// ever succeeds before the timeout, the errors collected during the LAST tick +// (the one which most recently called FailNow) are reported on the parent t. +// +// To abort the whole assertion immediately, use [CollectT.Cancel]. +func (c *CollectT) FailNow() { + c.errors = append(c.errors, errFailNow) + runtime.Goexit() +} + +// Cancel records a failure, cancels the [EventuallyWith] context, then exits +// the condition goroutine via [runtime.Goexit]. +// +// This aborts the whole assertion immediately, without waiting for the timeout. +// The errors collected during the cancelled tick are reported on the parent t. +// +// Use this when the condition can no longer be expected to succeed (e.g. an +// upstream resource has been observed in an unrecoverable state). For ordinary +// per-tick failures (e.g. "value not yet ready"), use [CollectT.FailNow] +// directly or transitively through [require] assertions. +func (c *CollectT) Cancel() { + c.errors = append(c.errors, errCancelled) + c.cancelContext() + runtime.Goexit() +} + +// Cancelf records a failure like [Cancel], with an additional custom message recorded. +func (c *CollectT) Cancelf(format string, msgAndArgs ...any) { + c.errors = append(c.errors, fmt.Errorf(format, msgAndArgs...)) + c.Cancel() +} + +func (c *CollectT) failed() bool { + return len(c.errors) != 0 +} + +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 + + return c +} diff --git a/internal/assertions/condition_synctest_test.go b/internal/assertions/async_synctest_test.go similarity index 94% rename from internal/assertions/condition_synctest_test.go rename to internal/assertions/async_synctest_test.go index 414276e27..169c07a94 100644 --- a/internal/assertions/condition_synctest_test.go +++ b/internal/assertions/async_synctest_test.go @@ -38,11 +38,11 @@ func runDualPath(t *testing.T, name string, fn func(t *testing.T)) { // Dual-path tests. // =========================================================================== -// TestConditionDualPath_EventuallyBehavior exercises [Eventually]'s core +// TestASyncDualPath_EventuallyBehavior exercises [Eventually]'s core // behavior through both real-time and bubble-wrapped test harnesses. // Using the harness-level bubble means the mock captures failures even // under fake time — the best of both worlds for behavior parity tests. -func TestConditionDualPath_EventuallyBehavior(t *testing.T) { +func TestASyncDualPath_EventuallyBehavior(t *testing.T) { runDualPath(t, "succeeds on first call", func(t *testing.T) { mock := new(errorsCapturingT) if !Eventually(mock, func() bool { return true }, testTimeout, testTick) { @@ -87,9 +87,9 @@ func TestConditionDualPath_EventuallyBehavior(t *testing.T) { }) } -// TestConditionDualPath_EventuallyWithErrorBehavior exercises the +// TestASyncDualPath_EventuallyWithErrorBehavior exercises the // context/error-returning variant of [Eventually] through both paths. -func TestConditionDualPath_EventuallyWithErrorBehavior(t *testing.T) { +func TestASyncDualPath_EventuallyWithErrorBehavior(t *testing.T) { runDualPath(t, "succeeds after returning transient errors", func(t *testing.T) { mock := new(errorsCapturingT) state := 0 @@ -131,9 +131,9 @@ func TestConditionDualPath_EventuallyWithErrorBehavior(t *testing.T) { }) } -// TestConditionDualPath_ConsistentlyWithErrorBehavior exercises the +// TestASyncDualPath_ConsistentlyWithErrorBehavior exercises the // context/error-returning variant of [Consistently] through both paths. -func TestConditionDualPath_ConsistentlyWithErrorBehavior(t *testing.T) { +func TestASyncDualPath_ConsistentlyWithErrorBehavior(t *testing.T) { runDualPath(t, "succeeds when always nil", func(t *testing.T) { mock := new(errorsCapturingT) cond := func(_ context.Context) error { return nil } @@ -167,8 +167,8 @@ func TestConditionDualPath_ConsistentlyWithErrorBehavior(t *testing.T) { }) } -// TestConditionDualPath_NeverBehavior exercises [Never] through both paths. -func TestConditionDualPath_NeverBehavior(t *testing.T) { +// TestASyncDualPath_NeverBehavior exercises [Never] through both paths. +func TestASyncDualPath_NeverBehavior(t *testing.T) { runDualPath(t, "succeeds when condition never true", func(t *testing.T) { mock := new(errorsCapturingT) if !Never(mock, func() bool { return false }, testTimeout, testTick) { @@ -194,8 +194,8 @@ func TestConditionDualPath_NeverBehavior(t *testing.T) { }) } -// TestConditionDualPath_ConsistentlyBehavior exercises [Consistently] through both paths. -func TestConditionDualPath_ConsistentlyBehavior(t *testing.T) { +// TestASyncDualPath_ConsistentlyBehavior exercises [Consistently] through both paths. +func TestASyncDualPath_ConsistentlyBehavior(t *testing.T) { runDualPath(t, "succeeds when condition always true", func(t *testing.T) { mock := new(errorsCapturingT) if !Consistently(mock, func() bool { return true }, testTimeout, testTick) { @@ -221,8 +221,8 @@ func TestConditionDualPath_ConsistentlyBehavior(t *testing.T) { }) } -// TestConditionDualPath_EventuallyWithBehavior exercises [EventuallyWith] through both paths. -func TestConditionDualPath_EventuallyWithBehavior(t *testing.T) { +// TestASyncDualPath_EventuallyWithBehavior exercises [EventuallyWith] through both paths. +func TestASyncDualPath_EventuallyWithBehavior(t *testing.T) { runDualPath(t, "succeeds when no errors collected", func(t *testing.T) { mock := new(errorsCapturingT) cond := func(_ *CollectT) {} @@ -240,10 +240,10 @@ func TestConditionDualPath_EventuallyWithBehavior(t *testing.T) { }) } -// TestConditionDualPath_EventuallyWithContextBehavior exercises the +// TestASyncDualPath_EventuallyWithContextBehavior exercises the // context variant of [EventuallyWith] (`func(ctx, *CollectT)`) through // both paths. -func TestConditionDualPath_EventuallyWithContextBehavior(t *testing.T) { +func TestASyncDualPath_EventuallyWithContextBehavior(t *testing.T) { runDualPath(t, "succeeds after a few calls via context variant", func(t *testing.T) { mock := new(errorsCapturingT) counter := 0 @@ -285,10 +285,10 @@ func TestConditionDualPath_EventuallyWithContextBehavior(t *testing.T) { }) } -// TestConditionDualPath_EventuallySucceedQuickly verifies that Eventually +// TestASyncDualPath_EventuallySucceedQuickly verifies that Eventually // checks the condition BEFORE the first tick — by using a tick longer than // the total duration, only the initial-check path can succeed. -func TestConditionDualPath_EventuallySucceedQuickly(t *testing.T) { +func TestASyncDualPath_EventuallySucceedQuickly(t *testing.T) { t.Parallel() runDualPath(t, "should succeed before the first tick", dualEventuallySucceedBeforeFirstTick) } @@ -303,12 +303,12 @@ func dualEventuallySucceedBeforeFirstTick(t *testing.T) { } } -// TestConditionDualPath_EventuallyTimeoutBehavior verifies that Eventually +// TestASyncDualPath_EventuallyTimeoutBehavior verifies that Eventually // fails correctly when the condition is slower than the timeout (issue 805) // and when the parent context is cancelled. Both subtests run under real // time and inside a synctest bubble — reassurance that no semantic shift // occurs when switching modes. -func TestConditionDualPath_EventuallyTimeoutBehavior(t *testing.T) { +func TestASyncDualPath_EventuallyTimeoutBehavior(t *testing.T) { t.Parallel() runDualPath(t, "should fail on timeout", dualEventuallyTimeoutOnSlowCondition) runDualPath(t, "should fail when parent context is cancelled", dualEventuallyTimeoutOnParentCancellation) @@ -369,12 +369,12 @@ func dualEventuallyTimeoutOnParentCancellation(t *testing.T) { } } -// TestConditionDualPath_PollUntilTimeoutBehavior exercises the shared +// TestASyncDualPath_PollUntilTimeoutBehavior exercises the shared // poll-until-timeout subtests for [Never] and [Consistently] through both // real-time and synctest paths. These subtests cover timing-independent // invariants (initial-check before first tick, flipped-condition failure, // parent-context cancellation) and benefit from dual-path for determinism. -func TestConditionDualPath_PollUntilTimeoutBehavior(t *testing.T) { +func TestASyncDualPath_PollUntilTimeoutBehavior(t *testing.T) { t.Parallel() for c := range pollUntilTimeoutCases() { t.Run(c.name, func(t *testing.T) { @@ -460,15 +460,15 @@ func dualPollCaseParentCancelled(c pollUntilTimeoutCase) func(*testing.T) { } } -// TestConditionDualPath_EventuallyWithCollectBehavior exercises the +// TestASyncDualPath_EventuallyWithCollectBehavior exercises the // behavioral subtests of [EventuallyWith] through both paths. The // [CollectT]-based invariants (FailNow retries, Cancel short-circuits, // initial-check before first tick, etc.) are independent of timing and // work identically under real time and fake time. // // The nanosecond-tick "race trigger" subtest of [EventuallyWith] is NOT -// migrated here — see [TestConditionEventuallyWith] for the rationale. -func TestConditionDualPath_EventuallyWithCollectBehavior(t *testing.T) { +// migrated here — see [TestASyncEventuallyWith] for the rationale. +func TestASyncDualPath_EventuallyWithCollectBehavior(t *testing.T) { runDualPath(t, "should complete with false (tolerant count)", dualEventuallyWithCompleteFalse) runDualPath(t, "should complete with true", dualEventuallyWithCompleteTrue) runDualPath(t, "should complete with fail, with latest failed condition", dualEventuallyWithFailLatest) diff --git a/internal/assertions/async_test.go b/internal/assertions/async_test.go new file mode 100644 index 000000000..6ea3412e6 --- /dev/null +++ b/internal/assertions/async_test.go @@ -0,0 +1,413 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package assertions + +import ( + "errors" + "fmt" + "iter" + "slices" + "sort" + "sync" + "testing" + "time" +) + +const ( + testTimeout = 100 * time.Millisecond + testTick = 20 * time.Millisecond +) + +// This test is deliberately NOT dual-path: it asserts that there are no leaking go routines +// when real time tickers are used. This is naturally verified when running in a syntest bubble. +func TestASyncEventuallyNoLeak(t *testing.T) { + t.Parallel() + + t.Run("should output messages in a determined order", func(t *testing.T) { + t.Parallel() + + /* Original output (replaced by integers) from https://github.com/stretchr/testify/issues/1611 + condition_test.go:150: 2026-01-11 12:11:49.34854116 +0100 CET m=+0.000641595 Condition: inEventually = true + condition_test.go:152: 2026-01-11 12:11:49.84944055 +0100 CET m=+0.501540975 Condition: inEventually = true + condition_test.go:147: 2026-01-11 12:11:49.849484723 +0100 CET m=+0.501585149 Condition: end. + condition_test.go:160: 2026-01-11 12:11:49.849500022 +0100 CET m=+0.501600447 Eventually done + condition_test.go:163: 2026-01-11 12:11:49.849508218 +0100 CET m=+0.501608643 End of TestASyncEventuallyNoLeak/should_output_messages_in_a_determined_order + */ + mock := new(errorsCapturingT) + done := make(chan struct{}, 1) + recordedActions := make([]int, 0, 5) + var mx sync.Mutex + record := func(action int) { + mx.Lock() + defer mx.Unlock() + + recordedActions = append(recordedActions, action) + } + + inEventually := true + Eventually(mock, + func() bool { + defer func() { + record(2) + done <- struct{}{} + }() + if inEventually { + record(0) + } + time.Sleep(5 * testTimeout) + if inEventually { + record(1) + } + return true + }, + testTimeout, + testTick, + ) + + inEventually = false + record(3) + + <-done + record(4) + record(5) + + const expectedActions = 6 + if len(recordedActions) != expectedActions { + t.Errorf("expected %d actions to be recorded, got %d", expectedActions, len(recordedActions)) + } + if !sort.IntsAreSorted(recordedActions) { + t.Errorf("expected recorded actions to be ordered, got %v", recordedActions) + } + }) + + t.Run("should not leak a go routine for condition execution", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + done := make(chan bool, 1) + + inEventually := true + Eventually(mock, + func() bool { + defer func() { + done <- inEventually + }() + time.Sleep(5 * testTimeout) + + return true + }, + testTimeout, + testTick, + ) + + inEventually = false + result := <-done + if !result { + t.Error("Condition should end while Eventually still runs.") + } + }) +} + +// TestASyncEventuallyWith keeps only the nanosecond-tick "race trigger" +// subtest of [EventuallyWith]. All behavior-oriented subtests have been +// migrated to [TestASyncDualPath_EventuallyWithCollectBehavior] in +// condition_synctest_test.go, where they run under both real time and a +// synctest bubble. +// +// This test is deliberately NOT dual-path: it uses a nanosecond tick +// to force real-time scheduling races between the poller, the ticker, +// and the condition goroutine. Under synctest, ticks fire deterministically +// from a fake clock — so there are no real races to exercise. Keeping this +// test real-time-only preserves its purpose as a smoke test against +// concurrency regressions that only manifest under real scheduler pressure. +func TestASyncEventuallyWith(t *testing.T) { + t.Parallel() + + t.Run("should complete with fail, on a nanosecond tick (real-time race trigger)", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + cond := func(c *CollectT) { + Fail(c, "condition fixed failure") + } + + // Nanosecond tick to provoke real-time scheduling races. + if EventuallyWith(mock, cond, testTimeout, time.Nanosecond) { + t.Error("expected EventuallyWith to return false") + } + const expectedErrors = 3 + if len(mock.errors) != expectedErrors { + t.Errorf("expected %d errors (1 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) + } + }) +} + +func TestASyncErrorMessages(t *testing.T) { + t.Parallel() + + runFailCases(t, conditionFailCases()) +} + +func conditionFailCases() iter.Seq[failCase] { + return slices.Values([]failCase{ + { + name: "Condition/false", + assertion: func(t T) bool { return Condition(t, func() bool { return false }) }, + wantError: "condition failed", + }, + { + name: "Blocked/non-channel", + assertion: func(t T) bool { return Blocked(t, "not a channel") }, + wantContains: []string{"Expected a channel but got"}, + }, + { + name: "Blocked/nil-interface", + assertion: func(t T) bool { return Blocked(t, nil) }, + wantContains: []string{"Expected a channel but got"}, + }, + { + name: "Blocked/buffered-with-value", + assertion: func(t T) bool { + ch := make(chan int, 1) + ch <- 42 + return Blocked(t, ch) + }, + wantContains: []string{"Channel receive should have blocked", "42"}, + }, + { + name: "BlockedT/buffered-with-value", + assertion: func(t T) bool { + ch := make(chan int, 1) + ch <- 42 + return BlockedT(t, ch) + }, + wantContains: []string{"Channel receive should have blocked", "42"}, + }, + { + name: "NotBlocked/empty-unbuffered", + assertion: func(t T) bool { + return NotBlocked(t, make(chan int)) + }, + wantContains: []string{"Channel receive should not have blocked"}, + }, + { + name: "NotBlockedT/empty-unbuffered", + assertion: func(t T) bool { + return NotBlockedT(t, make(chan int)) + }, + wantContains: []string{"Channel receive should not have blocked"}, + }, + { + name: "Blocked/closed-channel", + assertion: func(t T) bool { + ch := make(chan int) + close(ch) + return Blocked(t, ch) + }, + wantContains: []string{"channel was closed"}, + }, + { + name: "BlockedT/closed-channel", + assertion: func(t T) bool { + ch := make(chan int) + close(ch) + return BlockedT(t, ch) + }, + wantContains: []string{"channel was closed"}, + }, + { + name: "Blocked/send-only-rejected", + assertion: func(t T) bool { + ch := make(chan int) + var so chan<- int = ch + return Blocked(t, so) + }, + wantContains: []string{"channel direction"}, + }, + { + name: "NotBlocked/send-only-rejected", + assertion: func(t T) bool { + ch := make(chan int) + var so chan<- int = ch + return NotBlocked(t, so) + }, + wantContains: []string{"channel direction"}, + }, + }) +} + +// 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[func() bool], + 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") + }, + }) +} + +func TestASyncPanicRecovery(t *testing.T) { + t.Parallel() + + t.Run("Eventually survives a panicking condition and retries", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + condition := func() bool { + mu.Lock() + counter++ + n := counter + mu.Unlock() + if n < 3 { + panic("boom") + } + + return true + } + + if !Eventually(mock, condition, testTimeout, testTick) { + t.Error("expected Eventually to return true after recovering from panics") + } + mu.Lock() + got := counter + mu.Unlock() + if got < 3 { + t.Errorf("expected at least 3 calls, got %d", got) + } + }) + + t.Run("Eventually fails when condition always panics", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + condition := func() bool { + panic("persistent failure") + } + + if Eventually(mock, condition, testTimeout, testTick) { + t.Error("expected Eventually to return false when condition always panics") + } + }) + + t.Run("Never fails when condition panics", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + condition := func() bool { + panic("unexpected") + } + + if Never(mock, condition, testTimeout, testTick) { + t.Error("expected Never to return false when condition panics") + } + }) + + t.Run("Consistently fails when condition panics", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + condition := func() bool { + panic("unexpected") + } + + if Consistently(mock, condition, testTimeout, testTick) { + t.Error("expected Consistently to return false when condition panics") + } + }) + + t.Run("EventuallyWith survives a panicking condition and retries", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + condition := func(_ *CollectT) { + mu.Lock() + counter++ + n := counter + mu.Unlock() + if n < 3 { + panic("boom in collect") + } + } + + if !EventuallyWith(mock, condition, testTimeout, testTick) { + t.Error("expected EventuallyWith to return true after recovering from panics") + } + mu.Lock() + got := counter + mu.Unlock() + if got < 3 { + t.Errorf("expected at least 3 calls, got %d", got) + } + }) + + t.Run("EventuallyWith fails when condition always panics", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + condition := func(_ *CollectT) { + panic("always panics") + } + + if EventuallyWith(mock, condition, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false when condition always panics") + } + }) + + t.Run("EventuallyWith collects panic error via sentinel", func(t *testing.T) { + t.Parallel() + + mock := new(errorsCapturingT) + var counter int + var mu sync.Mutex + + condition := func(collect *CollectT) { + mu.Lock() + counter++ + n := counter + mu.Unlock() + + if n == 1 { + panic("boom on first tick") + } + // Subsequent ticks fail normally, preserving the panic error + // from the first tick in lastCollectedErrors. + Fail(collect, "still failing") + } + + if EventuallyWith(mock, condition, testTimeout, testTick) { + t.Error("expected EventuallyWith to return false") + } + }) + + t.Run("errConditionPanicked sentinel is detectable with errors.Is", func(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("%w: %v", errConditionPanicked, "test panic") + if !errors.Is(err, errConditionPanicked) { + t.Error("expected errors.Is to detect errConditionPanicked sentinel") + } + }) +} diff --git a/internal/assertions/condition.go b/internal/assertions/condition.go index 5dc2c7df0..266f7f90b 100644 --- a/internal/assertions/condition.go +++ b/internal/assertions/condition.go @@ -4,16 +4,8 @@ package assertions import ( - "context" - "errors" "fmt" "reflect" - "runtime" - "sync" - "sync/atomic" - "testing" - "testing/synctest" - "time" ) // Condition uses a comparison function to assert a complex condition. @@ -199,903 +191,3 @@ func NotBlockedT[E any, CHAN ~chan E](t T, ch CHAN, msgAndArgs ...any) bool { return Fail(t, "Channel receive should not have blocked", msgAndArgs...) } } - -// 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, at most until timeout, -// or until the parent context of the test is cancelled. -// -// 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] -// which is cancelled on test failure. -// -// # Usage -// -// 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 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. -// -// # Panic recovery -// -// If the condition panics, the panic is recovered and treated as a failed tick -// (equivalent to returning false or a non-nil error). For [Eventually], this means -// the poller retries on the next tick — if a later tick succeeds, the assertion -// succeeds. For [Never] and [Consistently], a panic is treated as the condition -// erroring, which causes immediate failure. -// -// The recovered panic is wrapped as an error with the sentinel [errConditionPanicked], -// detectable with [errors.Is]. -// -// # Attention point -// -// Time-based tests may be flaky in a resource-constrained environment such as a CI runner and may produce -// 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). -// -// # Synctest (opt-in) -// -// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run -// the polling loop inside a [testing/synctest] bubble, which uses a fake -// clock. This eliminates timing-induced flakiness and makes the tick count -// deterministic. See [WithSynctest] for the constraints (no real I/O in -// the condition, requires `*testing.T`). -// -// # 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[C Conditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { - // Domain: condition - // Opposite: Never - if h, ok := t.(H); ok { - h.Helper() - } - - return eventually(t, condition, timeout, tick, msgAndArgs...) -} - -// 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] ("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. -// -// # Usage -// -// assertions.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) -// -// See also [Eventually] for details about using context, concurrency, and panic recovery. -// -// # Alternative condition signature -// -// The simplest form of condition is: -// -// func() bool -// -// Use [Consistently] instead if you want to use a condition returning an error. -// -// # Panic recovery -// -// A panicking condition is treated as an error, causing [Never] to fail immediately. -// See [Eventually] for details. -// -// # Concurrency -// -// See [Eventually]. -// -// # Attention point -// -// See [Eventually]. -// -// # Synctest (opt-in) -// -// Wrap the condition with [WithSynctest] to run the polling loop inside a -// [testing/synctest] bubble, which uses a fake clock. This eliminates -// timing-induced flakiness and makes the tick count deterministic. See -// [WithSynctest] for the constraints (no real I/O in the condition, -// requires [*testing.T]). Note: [Never] does not accept the context/error -// form of condition, so [WithSynctestContext] does not apply here. -// -// # 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[C NeverConditioner](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, 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, concurrency, and panic recovery. -// -// # 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. -// -// # Panic recovery -// -// A panicking condition is treated as an error, causing [Consistently] to fail immediately. -// See [Eventually] for details. -// -// # Concurrency -// -// See [Eventually]. -// -// # Attention point -// -// See [Eventually]. -// -// # Synctest (opt-in) -// -// Wrap the condition with [WithSynctest] (or [WithSynctestContext]) to run -// the polling loop inside a [testing/synctest] bubble, which uses a fake -// clock. This eliminates timing-induced flakiness and makes the tick count -// deterministic. See [WithSynctest] for the constraints (no real I/O in -// the condition, requires [*testing.T]). -// -// # 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 consistently(t, condition, timeout, tick, msgAndArgs...) -} - -// 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] -// to accumulate errors from calling other assertions. -// -// 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 the timeout, the collected errors from the -// last tick are copied to t. -// -// Calling [CollectT.FailNow] (directly, or transitively through [require] assertions) -// fails the current tick only: the poller will retry on the next tick. This means -// [require]-style assertions inside [EventuallyWith] behave naturally — they abort -// the current evaluation and let the polling loop converge. -// -// To abort the whole assertion immediately (e.g. when the condition can no longer -// be expected to succeed), call [CollectT.Cancel]. -// -// # Usage -// -// externalValue := false -// go func() { -// time.Sleep(8*time.Second) -// externalValue = true -// }() -// -// assertions.EventuallyWith(t, func(c *assertions.CollectT) { -// // add assertions as needed; any assertion failure will fail the current tick -// assertions.True(c, externalValue, "expected 'externalValue' to be true") -// }, -// 10*time.Second, -// 1*time.Second, -// "external state has not changed to 'true'; still false", -// ) -// -// # 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 is wrapped in its own goroutine, so a call to [runtime.Goexit] -// (e.g. via [require] assertions or [CollectT.FailNow]) cleanly aborts only the -// current tick. -// -// # Panic recovery -// -// If the condition panics, the panic is recovered and recorded as an error in the -// [CollectT] for that tick. The poller treats it as a failed tick and retries on the -// next one. If the assertion times out, the panic error is included in the collected -// errors reported on the parent t. -// -// See [Eventually] for the general panic recovery semantics. -// -// # Synctest (opt-in) -// -// Wrap the condition with [WithSynctestCollect] (or [WithSynctestCollectContext]) -// to run the polling loop inside a [testing/synctest] bubble, which uses -// a fake clock. This eliminates timing-induced flakiness and makes the -// tick count deterministic. See [WithSynctest] for the constraints (no -// real I/O in the condition, requires [*testing.T]). -// -// # Examples -// -// 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 -// failure: func(c *CollectT) { c.Cancel() }, 100*time.Millisecond, 20*time.Millisecond -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, timeout, tick, msgAndArgs...) -} - -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() - } - - wantsBubble, cond := makeCondition(condition, false) - p := newConditionPoller(pollOptions{ - mode: pollUntilTrue, - failMessage: "condition never satisfied", - }) - - return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) -} - -func never[C NeverConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool { - if h, ok := t.(H); ok { - h.Helper() - } - - wantsBubble, cond := makeCondition(condition, true) - p := newConditionPoller(pollOptions{ - mode: pollUntilTimeout, - failMessage: "condition satisfied", - }) - - return runPoller(t, p, cond, timeout, tick, wantsBubble, msgAndArgs...) -} - -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() - } - - wantsBubble, cond := makeCondition(condition, false) - p := newConditionPoller(pollOptions{ - mode: pollUntilTimeout, - failMessage: "condition failed once", - }) - - return runPoller(t, p, cond, timeout, tick, wantsBubble, 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 - wantsBubble, fn := makeCollectibleCondition(collectCondition) - - condition := func(ctx context.Context) (err error) { - collector := new(CollectT).withCancelFunc(cancelFunc) - - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("%w: %v", errConditionPanicked, r) - collector.errors = append(collector.errors, err) - } - if collector.failed() { - lastCollectedErrors = collector.collected() - err = collector.last() - } - }() - - fn(ctx, collector) - - return nil - } - - copyCollected := func(tt T) { - for _, err := range lastCollectedErrors { - tt.Errorf("%v", err) - } - } - - p := newConditionPoller(pollOptions{ - mode: pollUntilTrue, - failMessage: "condition never satisfied", - onFailure: copyCollected, - onSetup: func(cancel func()) { cancelFunc = cancel }, - }) - - return runPoller(t, p, condition, timeout, tick, wantsBubble, msgAndArgs...) -} - -// runPoller dispatches the polling to either the real-time or the -// [synctest] bubble-wrapped path, based on whether the condition opted into -// fake time AND the caller passed a concrete [*testing.T]. -// -// When `wantsBubble` is true but `t` is not a `*testing.T` (e.g. a mock or -// [CollectT]), the call silently falls back to real-time polling. The -// synctest bubble requires a real `*testing.T`. -func runPoller(t T, p *conditionPoller, cond func(context.Context) error, timeout, tick time.Duration, wantsBubble bool, msgAndArgs ...any) bool { - if h, ok := t.(H); ok { - h.Helper() - } - - testingT, canBubble := t.(*testing.T) - if !wantsBubble || !canBubble { - return p.pollCondition(t, cond, timeout, tick, msgAndArgs...) - } - - var result bool - synctest.Test(testingT, func(inner *testing.T) { - result = p.pollCondition(inner, cond, timeout, tick, msgAndArgs...) - }) - - return result -} - -// makeCondition normalizes any variant from [Conditioner] or [NeverConditioner] -// into the unified `func(context.Context) error` form used by [pollCondition], -// and reports whether the caller opted into synctest-bubble polling. -// -// [WithSynctest] and [WithSynctestContext] are recognized as their underlying -// `func() bool` and `func(context.Context) error` forms with `wantsBubble = true`. -func makeCondition(condition any, reverse bool) (wantsBubble bool, cond func(context.Context) error) { - switch typed := condition.(type) { - case WithSynctest: - _, cond = makeCondition((func() bool)(typed), reverse) - return true, cond - case WithSynctestContext: - _, cond = makeCondition((func(context.Context) error)(typed), reverse) - return true, cond - case func() bool: - if !reverse { - return false, 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 false, 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 false, typed - default: // unreachable - panic(fmt.Errorf("unsupported Conditioner type. Mismatch with type constraint: %T", condition)) - } -} - -// makeCollectibleCondition normalizes any [CollectibleConditioner] variant -// into the unified `func(context.Context, *CollectT)` form, and reports -// whether the caller opted into synctest-bubble polling. -func makeCollectibleCondition(condition any) (wantsBubble bool, fn func(context.Context, *CollectT)) { - switch typed := condition.(type) { - case WithSynctestCollect: - _, fn = makeCollectibleCondition((func(*CollectT))(typed)) - return true, fn - case WithSynctestCollectContext: - _, fn = makeCollectibleCondition((func(context.Context, *CollectT))(typed)) - return true, fn - case func(*CollectT): - return false, func(ctx context.Context, collector *CollectT) { - select { - case <-ctx.Done(): - collector.Errorf("%v", ctx.Err()) - default: - typed(collector) - } - } - case func(context.Context, *CollectT): - return false, typed - default: // unreachable - panic(fmt.Errorf("unsupported CollectibleConditioner type. Mismatch with type constraint: %T", condition)) - } -} - -func recoverCondition(fn func(context.Context) error) func(context.Context) error { - return func(ctx context.Context) (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("%w: %v", errConditionPanicked, r) - } - }() - - return fn(ctx) - } -} - -type conditionPoller struct { - pollOptions - - ticker *time.Ticker - reported atomic.Bool - conditionChan chan func(context.Context) error - doneChan chan struct{} -} - -func newConditionPoller(o pollOptions) *conditionPoller { - return &conditionPoller{ - pollOptions: o, - } -} - -// initChannels creates the polling channels. MUST be called from inside -// [pollCondition] so that — when the caller activated a [synctest] bubble -// — the channels are bubble-owned. Receives on channels created outside -// the bubble do NOT count as durably blocking, which stalls the fake clock. -func (p *conditionPoller) initChannels() { - p.conditionChan = make(chan func(context.Context) error, 1) - p.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/Consistently). - pollUntilTimeout -) - -// pollOptions configures the condition polling behavior. -type pollOptions struct { - mode pollMode - failMessage string // error message added at the end of the stack - onFailure func(t T) // called on failure (e.g., to copy collected errors) - onSetup func(cancel func()) // called after context setup to expose cancel function -} - -// 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(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, timeout) - defer cancel() - - failFunc := p.failFunc(t, msgAndArgs...) - - // Allow caller to capture the cancel function (for eventuallyWithT's CollectT) - if p.onSetup != nil { - p.onSetup(cancel) - } - - condition = recoverCondition(condition) - - // Channels and ticker MUST be created inside pollCondition so that, - // when the caller activated a synctest bubble, they are bubble-owned - // primitives. Channels created outside the bubble do not count as - // durably blocking and would stall the fake clock. - p.initChannels() - - p.ticker = time.NewTicker(tick) - defer p.ticker.Stop() - - // Check the condition once first on the initial call. - p.conditionChan <- condition - - var wg sync.WaitGroup - - // Goroutine 1: Poll for the condition at every tick - wg.Add(1) - go p.pollAtTickFunc(parentCtx, ctx, condition, failFunc, &wg)() - - // Goroutine 2: Execute the condition and check results - wg.Add(1) - go p.executeCondition(parentCtx, ctx, failFunc, &wg)() - - wg.Wait() - - // Determine success based on mode - return p.determineOutcome(parentCtx, ctx, failFunc, t)() -} - -func (p *conditionPoller) failFunc(t T, msgAndArgs ...any) func(string) { - return func(reason string) { - if p.reported.CompareAndSwap(false, true) { - if reason != "" { - t.Errorf("%s", reason) - } - Fail(t, p.failMessage, msgAndArgs...) - } - } -} - -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() { - defer wg.Done() - - for { - select { - case <-parentCtx.Done(): - failFunc(parentCtx.Err().Error()) - return - case <-ctx.Done(): - return // timeout reached = success for Never - case <-p.doneChan: - return - case <-p.ticker.C: - // Nested select prevents blocking on channel send if context was cancelled - // between receiving the tick and attempting to send the condition. - select { - case <-parentCtx.Done(): - failFunc(parentCtx.Err().Error()) - return - case <-ctx.Done(): - return - case <-p.doneChan: - return - case p.conditionChan <- condition: - } - } - } - } - } - - // For Eventually: parent cancellation flows through ctx - return func() { - defer wg.Done() - - for { - select { - case <-ctx.Done(): - failFunc(ctx.Err().Error()) - return - case <-p.doneChan: - return - case <-p.ticker.C: - // Nested select prevents blocking on channel send if context was cancelled - // between receiving the tick and attempting to send the condition. - select { - case <-ctx.Done(): - failFunc(ctx.Err().Error()) - return - case <-p.doneChan: - return - case p.conditionChan <- condition: - } - } - } - } -} - -func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failFunc func(string), wg *sync.WaitGroup) func() { - if p.mode == pollUntilTimeout { - // For Never and Consistently - return func() { - defer wg.Done() - - for { - select { - case <-parentCtx.Done(): - failFunc(parentCtx.Err().Error()) - return - case <-ctx.Done(): - return // timeout = success - case fn := <-p.conditionChan: - var conditionWg sync.WaitGroup - conditionWg.Go(func() { // guards against the condition issue an early GoExit - - if err := fn(ctx); err != nil { - close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently - } - }) - conditionWg.Wait() - - select { - case <-p.doneChan: // done: early exit - return - default: - } - } - } - } - } - - // For Eventually - return func() { - defer wg.Done() - - for { - select { - case <-ctx.Done(): - failFunc(ctx.Err().Error()) - return - case fn := <-p.conditionChan: - var conditionWg sync.WaitGroup - conditionWg.Go(func() { // guards against the condition issue an early GoExit - - if err := fn(ctx); err == nil { - close(p.doneChan) // (condition true <=> err == nil) = success for Eventually - } - }) - conditionWg.Wait() - - select { - case <-p.doneChan: // done: early exit - return - default: - } - } - } - } -} - -func (p *conditionPoller) determineOutcome(parentCtx, ctx context.Context, failFunc func(string), t T) func() bool { - if p.mode == pollUntilTimeout { - return func() bool { - select { - case <-p.doneChan: - // For Never: doneChan closed means condition became true - // But if timeout was reached first (ctx.Err != nil), it's still a success. - // This handles the race between timeout and condition becoming true. - if ctx.Err() != nil { - return true - } - // Condition became true before timeout = failure - failFunc("") - return false - default: - // doneChan not closed - // For Never: timeout reached without condition being true = success - // We should return a success, unless the parent context has failed. - return parentCtx.Err() == nil - } - } - } - - return func() bool { - select { - case <-p.doneChan: - // For Eventually: doneChan closed means condition became true - if ctx.Err() != nil { - // Timeout occurred before or during success - if p.onFailure != nil { - p.onFailure(t) - } - return false - } - return true - default: - // doneChan not closed - // opts.mode = pollUntilTrue - // For Eventually: should not reach here (failFunc already called) - if p.onFailure != nil { - p.onFailure(t) - } - - return false - } - } -} - -func (p *conditionPoller) parentContextFromT(t T) context.Context { - var parentCtx context.Context - if withContext, ok := t.(contextualizer); ok { - parentCtx = withContext.Context() - } - if parentCtx == nil { - parentCtx = context.Background() - } - - return parentCtx -} - -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), timeout) - } else { - ctx, cancel = context.WithTimeout(parentCtx, timeout) - } - - return ctx, cancel -} - -// Sentinel errors recorded by async condition assertions. -// Kept package-private: callers should rely on observable behavior, not on -// the marker shape. They are distinguishable so future tooling can tell apart -// "tick aborted by require", "user explicitly cancelled the assertion", -// and "condition panicked". -var ( - errFailNow = errors.New("collect: failed now (tick aborted)") - errCancelled = errors.New("collect: cancelled (assertion aborted)") - errConditionPanicked = errors.New("condition panicked") -) - -// CollectT implements the [T] interface and collects all errors. -// -// [CollectT] is specifically intended to be used with [EventuallyWith] and -// should not be used outside of that context. -type CollectT struct { - // Domain: condition - // - // Maintainer: - // 1. FailNow() exits the current tick goroutine via runtime.Goexit (matching - // stretchr/testify semantics): require-style assertions abort the current - // evaluation and the poller retries on the next tick. It does NOT cancel - // the EventuallyWith context. - // 2. Cancel() is the explicit escape hatch: it cancels the EventuallyWith - // context before exiting via runtime.Goexit, aborting the whole assertion. - // 3. We no longer establish the distinction between c.errors nil or empty. - // Non-empty is an error, full stop. - // 4. Deprecated methods have been removed. - - // A slice of errors. Non-empty slice denotes a failure. - errors []error - - // cancelContext cancels the parent EventuallyWith context on Cancel(). - cancelContext func() -} - -// Helper is like [testing.T.Helper] but does nothing. -func (*CollectT) Helper() {} - -// Errorf collects the error. -func (c *CollectT) Errorf(format string, args ...any) { - c.errors = append(c.errors, fmt.Errorf(format, args...)) -} - -// FailNow records a failure for the current tick and exits the condition -// goroutine via [runtime.Goexit]. -// -// It does NOT cancel the [EventuallyWith] context: the poller will retry on -// the next tick. If a later tick succeeds, the assertion succeeds. If no tick -// ever succeeds before the timeout, the errors collected during the LAST tick -// (the one which most recently called FailNow) are reported on the parent t. -// -// To abort the whole assertion immediately, use [CollectT.Cancel]. -func (c *CollectT) FailNow() { - c.errors = append(c.errors, errFailNow) - runtime.Goexit() -} - -// Cancel records a failure, cancels the [EventuallyWith] context, then exits -// the condition goroutine via [runtime.Goexit]. -// -// This aborts the whole assertion immediately, without waiting for the timeout. -// The errors collected during the cancelled tick are reported on the parent t. -// -// Use this when the condition can no longer be expected to succeed (e.g. an -// upstream resource has been observed in an unrecoverable state). For ordinary -// per-tick failures (e.g. "value not yet ready"), use [CollectT.FailNow] -// directly or transitively through [require] assertions. -func (c *CollectT) Cancel() { - c.errors = append(c.errors, errCancelled) - c.cancelContext() - runtime.Goexit() -} - -// Cancelf records a failure like [Cancel], with an additional custom message recorded. -func (c *CollectT) Cancelf(format string, msgAndArgs ...any) { - c.errors = append(c.errors, fmt.Errorf(format, msgAndArgs...)) - c.Cancel() -} - -func (c *CollectT) failed() bool { - return len(c.errors) != 0 -} - -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 - - return c -} diff --git a/internal/assertions/condition_test.go b/internal/assertions/condition_test.go index 5076ba323..baa98889a 100644 --- a/internal/assertions/condition_test.go +++ b/internal/assertions/condition_test.go @@ -4,19 +4,9 @@ package assertions import ( - "errors" - "fmt" "iter" "slices" - "sort" - "sync" "testing" - "time" -) - -const ( - testTimeout = 100 * time.Millisecond - testTick = 20 * time.Millisecond ) func TestCondition(t *testing.T) { @@ -41,399 +31,6 @@ func TestCondition(t *testing.T) { }) } -// This test is deliberately NOT dual-path: it asserts that there are no leaking go routines -// when real time tickers are used. This is naturally verified when running in a syntest bubble. -func TestConditionEventuallyNoLeak(t *testing.T) { - t.Parallel() - - t.Run("should output messages in a determined order", func(t *testing.T) { - t.Parallel() - - /* Original output (replaced by integers) from https://github.com/stretchr/testify/issues/1611 - condition_test.go:150: 2026-01-11 12:11:49.34854116 +0100 CET m=+0.000641595 Condition: inEventually = true - condition_test.go:152: 2026-01-11 12:11:49.84944055 +0100 CET m=+0.501540975 Condition: inEventually = true - condition_test.go:147: 2026-01-11 12:11:49.849484723 +0100 CET m=+0.501585149 Condition: end. - condition_test.go:160: 2026-01-11 12:11:49.849500022 +0100 CET m=+0.501600447 Eventually done - condition_test.go:163: 2026-01-11 12:11:49.849508218 +0100 CET m=+0.501608643 End of TestConditionEventuallyNoLeak/should_output_messages_in_a_determined_order - */ - mock := new(errorsCapturingT) - done := make(chan struct{}, 1) - recordedActions := make([]int, 0, 5) - var mx sync.Mutex - record := func(action int) { - mx.Lock() - defer mx.Unlock() - - recordedActions = append(recordedActions, action) - } - - inEventually := true - Eventually(mock, - func() bool { - defer func() { - record(2) - done <- struct{}{} - }() - if inEventually { - record(0) - } - time.Sleep(5 * testTimeout) - if inEventually { - record(1) - } - return true - }, - testTimeout, - testTick, - ) - - inEventually = false - record(3) - - <-done - record(4) - record(5) - - const expectedActions = 6 - if len(recordedActions) != expectedActions { - t.Errorf("expected %d actions to be recorded, got %d", expectedActions, len(recordedActions)) - } - if !sort.IntsAreSorted(recordedActions) { - t.Errorf("expected recorded actions to be ordered, got %v", recordedActions) - } - }) - - t.Run("should not leak a go routine for condition execution", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - done := make(chan bool, 1) - - inEventually := true - Eventually(mock, - func() bool { - defer func() { - done <- inEventually - }() - time.Sleep(5 * testTimeout) - - return true - }, - testTimeout, - testTick, - ) - - inEventually = false - result := <-done - if !result { - t.Error("Condition should end while Eventually still runs.") - } - }) -} - -// TestConditionEventuallyWith keeps only the nanosecond-tick "race trigger" -// subtest of [EventuallyWith]. All behavior-oriented subtests have been -// migrated to [TestConditionDualPath_EventuallyWithCollectBehavior] in -// condition_synctest_test.go, where they run under both real time and a -// synctest bubble. -// -// This test is deliberately NOT dual-path: it uses a nanosecond tick -// to force real-time scheduling races between the poller, the ticker, -// and the condition goroutine. Under synctest, ticks fire deterministically -// from a fake clock — so there are no real races to exercise. Keeping this -// test real-time-only preserves its purpose as a smoke test against -// concurrency regressions that only manifest under real scheduler pressure. -func TestConditionEventuallyWith(t *testing.T) { - t.Parallel() - - t.Run("should complete with fail, on a nanosecond tick (real-time race trigger)", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - cond := func(c *CollectT) { - Fail(c, "condition fixed failure") - } - - // Nanosecond tick to provoke real-time scheduling races. - if EventuallyWith(mock, cond, testTimeout, time.Nanosecond) { - t.Error("expected EventuallyWith to return false") - } - const expectedErrors = 3 - if len(mock.errors) != expectedErrors { - t.Errorf("expected %d errors (1 from condition, 2 from Eventually), got %d", expectedErrors, len(mock.errors)) - } - }) -} - -func TestConditionErrorMessages(t *testing.T) { - t.Parallel() - - runFailCases(t, conditionFailCases()) -} - -func conditionFailCases() iter.Seq[failCase] { - return slices.Values([]failCase{ - { - name: "Condition/false", - assertion: func(t T) bool { return Condition(t, func() bool { return false }) }, - wantError: "condition failed", - }, - { - name: "Blocked/non-channel", - assertion: func(t T) bool { return Blocked(t, "not a channel") }, - wantContains: []string{"Expected a channel but got"}, - }, - { - name: "Blocked/nil-interface", - assertion: func(t T) bool { return Blocked(t, nil) }, - wantContains: []string{"Expected a channel but got"}, - }, - { - name: "Blocked/buffered-with-value", - assertion: func(t T) bool { - ch := make(chan int, 1) - ch <- 42 - return Blocked(t, ch) - }, - wantContains: []string{"Channel receive should have blocked", "42"}, - }, - { - name: "BlockedT/buffered-with-value", - assertion: func(t T) bool { - ch := make(chan int, 1) - ch <- 42 - return BlockedT(t, ch) - }, - wantContains: []string{"Channel receive should have blocked", "42"}, - }, - { - name: "NotBlocked/empty-unbuffered", - assertion: func(t T) bool { - return NotBlocked(t, make(chan int)) - }, - wantContains: []string{"Channel receive should not have blocked"}, - }, - { - name: "NotBlockedT/empty-unbuffered", - assertion: func(t T) bool { - return NotBlockedT(t, make(chan int)) - }, - wantContains: []string{"Channel receive should not have blocked"}, - }, - { - name: "Blocked/closed-channel", - assertion: func(t T) bool { - ch := make(chan int) - close(ch) - return Blocked(t, ch) - }, - wantContains: []string{"channel was closed"}, - }, - { - name: "BlockedT/closed-channel", - assertion: func(t T) bool { - ch := make(chan int) - close(ch) - return BlockedT(t, ch) - }, - wantContains: []string{"channel was closed"}, - }, - { - name: "Blocked/send-only-rejected", - assertion: func(t T) bool { - ch := make(chan int) - var so chan<- int = ch - return Blocked(t, so) - }, - wantContains: []string{"channel direction"}, - }, - { - name: "NotBlocked/send-only-rejected", - assertion: func(t T) bool { - ch := make(chan int) - var so chan<- int = ch - return NotBlocked(t, so) - }, - wantContains: []string{"channel direction"}, - }, - }) -} - -// 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[func() bool], - 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") - }, - }) -} - -func TestConditionPanicRecovery(t *testing.T) { - t.Parallel() - - t.Run("Eventually survives a panicking condition and retries", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - condition := func() bool { - mu.Lock() - counter++ - n := counter - mu.Unlock() - if n < 3 { - panic("boom") - } - - return true - } - - if !Eventually(mock, condition, testTimeout, testTick) { - t.Error("expected Eventually to return true after recovering from panics") - } - mu.Lock() - got := counter - mu.Unlock() - if got < 3 { - t.Errorf("expected at least 3 calls, got %d", got) - } - }) - - t.Run("Eventually fails when condition always panics", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func() bool { - panic("persistent failure") - } - - if Eventually(mock, condition, testTimeout, testTick) { - t.Error("expected Eventually to return false when condition always panics") - } - }) - - t.Run("Never fails when condition panics", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func() bool { - panic("unexpected") - } - - if Never(mock, condition, testTimeout, testTick) { - t.Error("expected Never to return false when condition panics") - } - }) - - t.Run("Consistently fails when condition panics", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func() bool { - panic("unexpected") - } - - if Consistently(mock, condition, testTimeout, testTick) { - t.Error("expected Consistently to return false when condition panics") - } - }) - - t.Run("EventuallyWith survives a panicking condition and retries", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - condition := func(_ *CollectT) { - mu.Lock() - counter++ - n := counter - mu.Unlock() - if n < 3 { - panic("boom in collect") - } - } - - if !EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return true after recovering from panics") - } - mu.Lock() - got := counter - mu.Unlock() - if got < 3 { - t.Errorf("expected at least 3 calls, got %d", got) - } - }) - - t.Run("EventuallyWith fails when condition always panics", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - condition := func(_ *CollectT) { - panic("always panics") - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false when condition always panics") - } - }) - - t.Run("EventuallyWith collects panic error via sentinel", func(t *testing.T) { - t.Parallel() - - mock := new(errorsCapturingT) - var counter int - var mu sync.Mutex - - condition := func(collect *CollectT) { - mu.Lock() - counter++ - n := counter - mu.Unlock() - - if n == 1 { - panic("boom on first tick") - } - // Subsequent ticks fail normally, preserving the panic error - // from the first tick in lastCollectedErrors. - Fail(collect, "still failing") - } - - if EventuallyWith(mock, condition, testTimeout, testTick) { - t.Error("expected EventuallyWith to return false") - } - }) - - t.Run("errConditionPanicked sentinel is detectable with errors.Is", func(t *testing.T) { - t.Parallel() - - err := fmt.Errorf("%w: %v", errConditionPanicked, "test panic") - if !errors.Is(err, errConditionPanicked) { - t.Error("expected errors.Is to detect errConditionPanicked sentinel") - } - }) -} - // ======================================= // Test ConditionBlocked / ConditionNotBlocked // ======================================= diff --git a/internal/assertions/doc.go b/internal/assertions/doc.go index 873e634e0..ee31f319b 100644 --- a/internal/assertions/doc.go +++ b/internal/assertions/doc.go @@ -13,6 +13,7 @@ // // # Domains // +// - async: running tests asynchronously against go routines // - boolean: asserting boolean values // - collection: asserting slices and maps // - comparison: comparing ordered values diff --git a/internal/assertions/yaml.go b/internal/assertions/yaml.go index 5fce6d53d..c36a6f4d7 100644 --- a/internal/assertions/yaml.go +++ b/internal/assertions/yaml.go @@ -155,7 +155,7 @@ func YAMLUnmarshalAsT[Object any, ADoc RText](t T, expected Object, yamlDoc ADoc // // # Usage // -// actual := struct { +// expected := struct { // A int `yaml:"a"` // }{ // A: 10, diff --git a/require/require_assertions.go b/require/require_assertions.go index f9a2b5b51..ca887a45f 100644 --- a/require/require_assertions.go +++ b/require/require_assertions.go @@ -3961,7 +3961,7 @@ func YAMLEqT[EDoc, ADoc RText](t T, expected EDoc, actual ADoc, msgAndArgs ...an // // # Usage // -// actual := struct { +// expected := struct { // A int `yaml:"a"` // }{ // A: 10,