Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .ci.govet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,14 @@

set -e

go vet ./...
# Run go vet on all packages. To exclude specific tests that are known to
# trigger vet warnings, use the 'novet' build tag. This is used in the
# following tests:
#
# require/requirements_testing_test.go:
#
# The 'testing' tests test testify behavior 😜 against a real testing.T,
# running tests in goroutines to capture Goexit behavior.
# Such usage triggers would normally trigger vet warnings (SA2002).

go vet -tags novet ./...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ _testmain.go

.DS_Store

# AI helpers
*-discussion.md

# Output of "go test -c"
/assert/assert.test
/require/require.test
Expand Down
101 changes: 101 additions & 0 deletions EVENTUALLY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Eventually

`assert.Eventually` waits for a user-supplied condition to become `true`. It is
most often used when code under test sets a value from another goroutine, so the
test needs to poll until the desired state is reached. This guide also covers
`assert.EventuallyWithT` and `assert.Never`.

## Variants at a glance

- `Eventually` polls until the condition returns `true` or the timeout fires.
- `EventuallyWithT` retries with a fresh `*assert.CollectT` each tick. It keeps
only the last tick's errors.
- `Never` makes sure the condition stays `false` for the whole timeout.

## Signature and scheduling

```go
func Eventually(
t TestingT,
condition func() bool,
waitFor time.Duration,
tick time.Duration,
msgAndArgs ...interface{}
) bool
```

- `condition` runs on its own goroutine. The first check happens immediately.
Later checks run every `tick` while it keeps returning `false`.
- `waitFor` sets the maximum polling time. When the deadline passes, the
assertion fails with "Condition never satisfied" and appends any
`msgAndArgs` to the output.
- The return value is `true` when the condition succeeds before the timeout.
It is `false` otherwise. The assertion also reports failures through `t`.

> [!Note]
> You must protect shared state between the test and the condition with
> mutexes, `atomic` types, or other synchronization tools.

## Exit and panic behavior

Since [PR #1809](https://github.com/stretchr/testify/pull/1809),
`assert.Eventually` handles each way the condition goroutine can finish:

- **Condition returns `true`:** Polling stops at once and the assertion passes.
- **Condition times out:** Polling keeps running until `waitFor` expires and the
test fails with "Condition never satisfied".
- **Condition panics:** The panic is not recovered. The Go runtime prints the
panic and stack trace, then stops the test run. This is the normal goroutine
panic path.
- **Condition calls `runtime.Goexit`:** The assertion now fails immediately with
"Condition exited unexpectedly". Earlier versions waited for `waitFor` and
could hang after `t.FailNow()` or `require.*`.

### `EventuallyWithT` specifics

`assert.EventuallyWithT` runs the same polling loop but gives each tick a new
`*assert.CollectT` named `collect`:

- Returning from the closure without errors on `collect` marks the condition as
satisfied and the assertion succeeds right away.
- Recording errors on `collect` (via `collect.Errorf`, `assert.*(collect, ...)`
helpers, or `collect.FailNow()`) fails only that tick. Polling keeps going,
and if the timeout hits, the last tick's errors replay on the parent `t`
before "Condition never satisfied".
- Call `collect.FailNow()` to exit the tick quickly and move to the next poll.
- If the closure exits via `runtime.Goexit` without first recording errors on
`collect`—for example, by calling `require.*` on the parent `t`—the assertion
fails immediately with "Condition exited unexpectedly".
- Panics behave the same as in `assert.Eventually`. They are not recovered and
stop the test process.

Use `collect` for assertions you want to retry on each tick. Call `require.*`
on the parent `t` when you want the test to stop immediately. The same rules
apply to `require.EventuallyWithT` and its helpers.

## `Never` specifics

`assert.Never` uses the same polling loop as `Eventually` but expects the
condition to stay `false`. If the condition ever returns `true`, the assertion
fails immediately with "Condition satisfied".

- If the condition panics, the panic is not recovered and the test process
terminates.
- If the condition calls `runtime.Goexit`, the assertion fails immediately with
"Condition exited unexpectedly".
- These behaviors match those of `assert.Eventually`.

> [!Note]
> `Never` only succeeds when it lasts the full timeout, so it cannot finish
> early. Prefer using `Eventually` to keep tests fast.

## Usage tips

- Pick a `tick` that balances quick feedback with the work the condition does.
Very small ticks can turn into a busy loop.
- Run `go test -race` when `Eventually` works with other goroutines. Data races
cause more flakiness than the assertion itself.
- Use `assert.EventuallyWithT` when you need richer diagnostics or multiple
errors. Record the failures on the provided `CollectT` value.
- Call `require.*` on the parent `t` inside the condition when you need to stop
the test immediately.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Testify - Thou Shalt Write Tests
================================

> [!NOTE]
> Testify is being maintained at v1, no breaking changes will be accepted in this repo.
> Testify is being maintained at v1, no breaking changes will be accepted in this repo.
> [See discussion about v2](https://github.com/stretchr/testify/discussions/1560).

[![Build Status](https://github.com/stretchr/testify/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/stretchr/testify/actions/workflows/main.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/stretchr/testify)](https://goreportcard.com/report/github.com/stretchr/testify) [![PkgGoDev](https://pkg.go.dev/badge/github.com/stretchr/testify)](https://pkg.go.dev/github.com/stretchr/testify)
Expand Down Expand Up @@ -31,6 +31,17 @@ The `assert` package provides some helpful methods that allow you to write bette
* Prints friendly, easy to read failure descriptions
* Allows for very readable code
* Optionally annotate each assertion with a message
* Supports polling assertions via `assert.Eventually`, `assert.EventuallyWithT`, and similar functions

> [!Note]
> See [EVENTUALLY.md](EVENTUALLY.md) for details about timing, exit behavior, and panic handling,
> and read the source code and source code comments carefully.
>
> The `Eventually` functions behavior got some recent bug fixes and behavior changes for longstanding issues.
>
> 👉️ Please **read** the [document](EVENTUALLY.md) and the **source code comments**! \
> 👉️ Please **adapt** your code if you were relying on the buggy behavior!


See it in action:

Expand Down
63 changes: 58 additions & 5 deletions _codegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,64 @@ func (f *testFunc) Comment() string {
}

func (f *testFunc) CommentFormat() string {
search := fmt.Sprintf("%s", f.DocInfo.Name)
replace := fmt.Sprintf("%sf", f.DocInfo.Name)
comment := strings.Replace(f.Comment(), search, replace, -1)
exp := regexp.MustCompile(replace + `\(((\(\)|[^\n])+)\)`)
return exp.ReplaceAllString(comment, replace+`($1, "error message %s", "formatted")`)
name := f.DocInfo.Name
nameF := name + "f"
comment := f.Comment()

// 1. Best effort replacer for mentions, calls, etc.
// that can preserve references to the original function.
bestEffortReplacer := strings.NewReplacer(
"["+name+"]", "["+name+"]", // ref to origin func, keep as is
"["+nameF+"]", "["+nameF+"]", // ref to format func code, keep as is
name+" ", nameF+" ", // mention in text -> replace
name+"(", nameF+"(", // function call -> replace
name+",", nameF+",", // mention in enumeration -> replace
name+".", nameF+".", // closure of sentence -> replace
name+"\n", nameF+"\n", // end of line -> replace
)
comment = bestEffortReplacer.Replace(comment)

// 2 Find single line assertion calls of any kind, exluding multi-line ones.
// example: // assert.Equal(t, expected, actual) <-- the call must be closed on the same line
assertFormatFuncExp := regexp.MustCompile(`assert\.` + nameF + `\(.*\)`)
// 2.1 Extract params and existing message if any.
// Note: Unless we start parsing the parameters properly, this is a best-effort solution.
// If an assertion call ends with a string parameter, we consider that the message.
// Please adjust the assertion examples accordingly if needed.
const minErrorMessageLength = 10
paramsExp := regexp.MustCompile(`([^()]*)\((.*)\)`)
strParamExp := regexp.MustCompile(`"[^"]*"$`)
comment = assertFormatFuncExp.ReplaceAllStringFunc(comment, func(s string) string {
oBraces := strings.Count(s, "(")
cBraces := strings.Count(s, ")")
if oBraces != cBraces {
// Skip multi-line examples, where assert call is not closed on the same line.
return s
}

m := paramsExp.FindStringSubmatch(s)
prefix, params, msg := m[1], strings.Split(m[2], ", "), "error message"

last := strings.TrimSpace(params[len(params)-1])
// If last param is a string, consider it the message.
// It is is too short, it is an assertion value, not a message.
if strParamExp.MatchString(last) && len(last) > minErrorMessageLength+2 {
msg = strings.Trim(msg, `"`) + ":"
params = params[:len(params)-1]
}

// Rebuild the call with formatted message, reuse existing message if any.
params = append(params, `"`+msg+` %s", "formatted"`)
return prefix + "(" + strings.Join(params, ", ") + ")"
})

// 3. Replace calls to multi-line assertions end. Examles like:
// search: // }, time.Second, 10*time.Millisecond, "condition must never be true")
// replace: // }, time.Second, 10*time.Millisecond, "condition must never be true, more: %s", "formatted")
endFuncWithStringExp := regexp.MustCompile(`(//[\s]*\},.* )"([^"]+)"\)(\n|$)`)
comment = endFuncWithStringExp.ReplaceAllString(comment, `$1 "$2, more: %s", "formatted")$3`)

return comment
}

func (f *testFunc) CommentWithoutT(receiver string) string {
Expand Down
Loading