Skip to content

Commit

Permalink
Eventually an Consistently can now be stopped early with StopTrying(m…
Browse files Browse the repository at this point in the history
…essage) and StopTrying(message).Now()
  • Loading branch information
onsi committed Oct 11, 2022
1 parent a2dc7c3 commit 52976bb
Show file tree
Hide file tree
Showing 4 changed files with 311 additions and 46 deletions.
49 changes: 49 additions & 0 deletions docs/index.md
Expand Up @@ -477,6 +477,55 @@ If `Consistently` is passed a `context.Context` it will exit if the context is c

> Developers often try to use `runtime.Gosched()` to nudge background goroutines to run. This can lead to flaky tests as it is not deterministic that a given goroutine will run during the `Gosched`. `Consistently` is particularly handy in these cases: it polls for 100ms which is typically more than enough time for all your Goroutines to run. Yes, this is basically like putting a time.Sleep() in your tests... Sometimes, when making negative assertions in a concurrent world, that's the best you can do!
### Bailing Out Early

There are cases where you need to signal to `Eventually` and `Consistently` that they should stop trying. Gomega provides`StopTrying(MESSAGE)` to allow you to send that signal. There are two ways to use `StopTrying`.

First, you can return `StopTrying(MESSAGE)` as an error. Consider, for example, the case where `Eventually` is searching through a set of possible queries with a server:

```go
playerIndex, numPlayers := 0, 11
Eventually(func() (string, error) {
name := client.FetchPlayer(playerIndex)
playerIndex += 1
if playerIndex == numPlayers {
return name, StopTrying("No more players left")
} else {
return name, nil
}
}).Should(Equal("Patrick Mahomes"))
```

Here we return a `StopTrying(MESSAGE)` error to tell `Eventually` that we've looked through all possible players and that it should stop. Note that `Eventually` will check last name returned by this function and succeed if that name is the desired name.

You can also call `StopTrying(MESSAGE).Now()` to immediately end execution of the function. Consider, for example, the case of a client communicating with a server that experiences an irrevocable error:

```go
Eventually(func() []string {
names, err := client.FetchAllPlayers()
if err == client.IRRECOVERABLE_ERROR {
StopTrying("An irrecoverable error occurred").Now()
}
return names
}).Should(ContainElement("Patrick Mahomes"))
```

calling `.Now()` will trigger a panic that will signal to `Eventually` that the it should stop trying.

You can also use both verison of `StopTrying()` with `Consistently`. Since `Consistently` is validating that something is _true_ consitently for the entire requested duration sending a `StopTrying()` signal is interpreted as success. Here's a somewhat contrived example:

```go
go client.DoSomethingComplicated()
Consistently(func() int {
if client.Status() == client.DoneStatus {
StopTrying("Client finished").Now()
}
return client.NumErrors()
}).Should(Equal(0))
```

here we succeed because no errors were identified while the client was working.

### Modifying Default Intervals

By default, `Eventually` will poll every 10 milliseconds for up to 1 second and `Consistently` will monitor every 10 milliseconds for up to 100 milliseconds. You can modify these defaults across your test suite with:
Expand Down
33 changes: 33 additions & 0 deletions gomega_dsl.go
Expand Up @@ -405,6 +405,39 @@ func ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{})
return Default.ConsistentlyWithOffset(offset, actual, args...)
}

/*
StopTrying can be used to signal to Eventually and Consistently that the polled function will not change
and that they should stop trying. In the case of Eventually, if a match does not occur in this, final, iteration then a failure will result. In the case of Consistently, as long as this last iteration satisfies the match, the assertion will be considered successful.
You can send the StopTrying signal by either returning a StopTrying("message") messages as an error from your passed-in function _or_ by calling StopTrying("message").Now() to trigger a panic and end execution.
Here are a couple of examples. This is how you might use StopTrying() as an error to signal that Eventually should stop:
playerIndex, numPlayers := 0, 11
Eventually(func() (string, error) {
name := client.FetchPlayer(playerIndex)
playerIndex += 1
if playerIndex == numPlayers {
return name, StopTrying("No more players left")
} else {
return name, nil
}
}).Should(Equal("Patrick Mahomes"))
note that the final `name` returned alongside `StopTrying()` will be processed.
And here's an example where `StopTrying().Now()` is called to halt execution immediately:
Eventually(func() []string {
names, err := client.FetchAllPlayers()
if err == client.IRRECOVERABLE_ERROR {
StopTrying("Irrecoverable error occurred").Now()
}
return names
}).Should(ContainElement("Patrick Mahomes"))
*/
var StopTrying = internal.StopTrying

// SetDefaultEventuallyTimeout sets the default timeout duration for Eventually. Eventually will repeatedly poll your condition until it succeeds, or until this timeout elapses.
func SetDefaultEventuallyTimeout(t time.Duration) {
Default.SetDefaultEventuallyTimeout(t)
Expand Down
131 changes: 103 additions & 28 deletions internal/async_assertion.go
Expand Up @@ -11,6 +11,36 @@ import (
"github.com/onsi/gomega/types"
)

type StopTryingError interface {
error
Now()
wasViaPanic() bool
}

type stopTryingError struct {
message string
viaPanic bool
}

func (s *stopTryingError) Error() string {
return s.message
}

func (s *stopTryingError) Now() {
s.viaPanic = true
panic(s)
}

func (s *stopTryingError) wasViaPanic() bool {
return s.viaPanic
}

var stopTryingErrorType = reflect.TypeOf(&stopTryingError{})

var StopTrying = func(message string) StopTryingError {
return &stopTryingError{message: message}
}

type AsyncAssertionType uint

const (
Expand Down Expand Up @@ -119,23 +149,36 @@ func (assertion *AsyncAssertion) buildDescription(optionalDescription ...interfa
return fmt.Sprintf(optionalDescription[0].(string), optionalDescription[1:]...) + "\n"
}

func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (interface{}, error) {
func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (interface{}, error, StopTryingError) {
var err error
var stopTrying StopTryingError

if len(values) == 0 {
return nil, fmt.Errorf("No values were returned by the function passed to Gomega")
return nil, fmt.Errorf("No values were returned by the function passed to Gomega"), stopTrying
}
actual := values[0].Interface()
if actual != nil && reflect.TypeOf(actual) == stopTryingErrorType {
stopTrying = actual.(StopTryingError)
}
for i, extraValue := range values[1:] {
extra := extraValue.Interface()
if extra == nil {
continue
}
zero := reflect.Zero(extraValue.Type()).Interface()
extraType := reflect.TypeOf(extra)
if extraType == stopTryingErrorType {
stopTrying = extra.(StopTryingError)
continue
}
zero := reflect.Zero(extraType).Interface()
if reflect.DeepEqual(extra, zero) {
continue
}
return actual, fmt.Errorf("Unexpected non-nil/non-zero argument at index %d:\n\t<%T>: %#v", i+1, extra, extra)
if err == nil {
err = fmt.Errorf("Unexpected non-nil/non-zero argument at index %d:\n\t<%T>: %#v", i+1, extra, extra)
}
}
return actual, nil
return actual, err, stopTrying
}

var gomegaType = reflect.TypeOf((*types.Gomega)(nil)).Elem()
Expand Down Expand Up @@ -169,9 +212,9 @@ You can learn more at https://onsi.github.io/gomega/#eventually
`, assertion.asyncType, t, t.NumIn(), numProvided, have, assertion.asyncType)
}

func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error), error) {
func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error, StopTryingError), error) {
if !assertion.actualIsFunc {
return func() (interface{}, error) { return assertion.actual, nil }, nil
return func() (interface{}, error, StopTryingError) { return assertion.actual, nil, nil }, nil
}
actualValue := reflect.ValueOf(assertion.actual)
actualType := reflect.TypeOf(assertion.actual)
Expand All @@ -180,7 +223,20 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error
if numIn == 0 && numOut == 0 {
return nil, assertion.invalidFunctionError(actualType)
} else if numIn == 0 {
return func() (interface{}, error) { return assertion.processReturnValues(actualValue.Call([]reflect.Value{})) }, nil
return func() (actual interface{}, err error, stopTrying StopTryingError) {
defer func() {
if e := recover(); e != nil {
if reflect.TypeOf(e) == stopTryingErrorType {
stopTrying = e.(StopTryingError)
} else {
panic(e)
}
}
}()

actual, err, stopTrying = assertion.processReturnValues(actualValue.Call([]reflect.Value{}))
return
}, nil
}
takesGomega, takesContext := actualType.In(0).Implements(gomegaType), actualType.In(0).Implements(contextType)
if takesGomega && numIn > 1 && actualType.In(1).Implements(contextType) {
Expand Down Expand Up @@ -222,32 +278,36 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error
return nil, assertion.argumentMismatchError(actualType, len(inValues))
}

return func() (actual interface{}, err error) {
return func() (actual interface{}, err error, stopTrying StopTryingError) {
var values []reflect.Value
assertionFailure = nil
defer func() {
if numOut == 0 {
actual = assertionFailure
} else {
actual, err = assertion.processReturnValues(values)
actual, err, stopTrying = assertion.processReturnValues(values)
if assertionFailure != nil {
err = assertionFailure
}
}
if e := recover(); e != nil && assertionFailure == nil {
panic(e)
if e := recover(); e != nil {
if reflect.TypeOf(e) == stopTryingErrorType {
stopTrying = e.(StopTryingError)
} else if assertionFailure == nil {
panic(e)
}
}
}()
values = actualValue.Call(inValues)
return
}, nil
}

func (assertion *AsyncAssertion) matcherMayChange(matcher types.GomegaMatcher, value interface{}) bool {
if assertion.actualIsFunc {
return true
func (assertion *AsyncAssertion) matcherSaysStopTrying(matcher types.GomegaMatcher, value interface{}) StopTryingError {
if assertion.actualIsFunc || types.MatchMayChangeInTheFuture(matcher, value) {
return nil
}
return types.MatchMayChangeInTheFuture(matcher, value)
return StopTrying("No future change is possible. Bailing out early")
}

type contextWithAttachProgressReporter interface {
Expand All @@ -261,7 +321,6 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch

var matches bool
var err error
mayChange := true

assertion.g.THelper()

Expand All @@ -271,9 +330,11 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
return false
}

value, err := pollActual()
value, err, stopTrying := pollActual()
if err == nil {
mayChange = assertion.matcherMayChange(matcher, value)
if stopTrying == nil {
stopTrying = assertion.matcherSaysStopTrying(matcher, value)
}
matches, err = matcher.Match(value)
}

Expand Down Expand Up @@ -316,19 +377,27 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
return true
}

if !mayChange {
fail("No future change is possible. Bailing out early")
if stopTrying != nil {
fail(stopTrying.Error() + " -")
return false
}

select {
case <-time.After(assertion.pollingInterval):
v, e := pollActual()
v, e, st := pollActual()
if st != nil && st.wasViaPanic() {
// we were told to stop trying via panic - which means we dont' have reasonable new values
// we should simply use the old values and exit now
fail(st.Error() + " -")
return false
}
lock.Lock()
value, err = v, e
value, err, stopTrying = v, e, st
lock.Unlock()
if err == nil {
mayChange = assertion.matcherMayChange(matcher, value)
if stopTrying == nil {
stopTrying = assertion.matcherSaysStopTrying(matcher, value)
}
matches, e = matcher.Match(value)
lock.Lock()
err = e
Expand All @@ -349,18 +418,24 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
return false
}

if !mayChange {
if stopTrying != nil {
return true
}

select {
case <-time.After(assertion.pollingInterval):
v, e := pollActual()
v, e, st := pollActual()
if st != nil && st.wasViaPanic() {
// we were told to stop trying via panic - which means we made it this far and should return successfully
return true
}
lock.Lock()
value, err = v, e
value, err, stopTrying = v, e, st
lock.Unlock()
if err == nil {
mayChange = assertion.matcherMayChange(matcher, value)
if stopTrying == nil {
stopTrying = assertion.matcherSaysStopTrying(matcher, value)
}
matches, e = matcher.Match(value)
lock.Lock()
err = e
Expand Down

0 comments on commit 52976bb

Please sign in to comment.