diff --git a/docs/index.md b/docs/index.md index 42f99f65d..0c351d5ac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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: diff --git a/gomega_dsl.go b/gomega_dsl.go index 5f7158813..e38409e64 100644 --- a/gomega_dsl.go +++ b/gomega_dsl.go @@ -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) diff --git a/internal/async_assertion.go b/internal/async_assertion.go index ed09527e2..df552fdb5 100644 --- a/internal/async_assertion.go +++ b/internal/async_assertion.go @@ -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 ( @@ -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() @@ -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) @@ -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) { @@ -222,20 +278,24 @@ 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) @@ -243,11 +303,11 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error }, 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 { @@ -261,7 +321,6 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch var matches bool var err error - mayChange := true assertion.g.THelper() @@ -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) } @@ -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 @@ -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 diff --git a/internal/async_assertion_test.go b/internal/async_assertion_test.go index 71f12c835..d62606f36 100644 --- a/internal/async_assertion_test.go +++ b/internal/async_assertion_test.go @@ -907,27 +907,135 @@ var _ = Describe("Asynchronous Assertions", func() { }) }) - Describe("when using OracleMatchers", func() { - It("stops and gives up with an appropriate failure message if the OracleMatcher says things can't change", func() { - c := make(chan bool) - close(c) - - t := time.Now() - ig.G.Eventually(c).WithTimeout(100*time.Millisecond).WithPolling(10*time.Millisecond).Should(Receive(), "Receive is an OracleMatcher that gives up if the channel is closed") - Ω(time.Since(t)).Should(BeNumerically("<", 90*time.Millisecond)) - Ω(ig.FailureMessage).Should(ContainSubstring("No future change is possible.")) - Ω(ig.FailureMessage).Should(ContainSubstring("The channel is closed.")) + Describe("Stopping Early", func() { + Describe("when using OracleMatchers", func() { + It("stops and gives up with an appropriate failure message if the OracleMatcher says things can't change", func() { + c := make(chan bool) + close(c) + + t := time.Now() + ig.G.Eventually(c).WithTimeout(100*time.Millisecond).WithPolling(10*time.Millisecond).Should(Receive(), "Receive is an OracleMatcher that gives up if the channel is closed") + Ω(time.Since(t)).Should(BeNumerically("<", 90*time.Millisecond)) + Ω(ig.FailureMessage).Should(ContainSubstring("No future change is possible.")) + Ω(ig.FailureMessage).Should(ContainSubstring("The channel is closed.")) + }) + + It("never gives up if actual is a function", func() { + c := make(chan bool) + close(c) + + t := time.Now() + ig.G.Eventually(func() chan bool { return c }).WithTimeout(100*time.Millisecond).WithPolling(10*time.Millisecond).Should(Receive(), "Receive is an OracleMatcher that gives up if the channel is closed") + Ω(time.Since(t)).Should(BeNumerically(">=", 90*time.Millisecond)) + Ω(ig.FailureMessage).ShouldNot(ContainSubstring("No future change is possible.")) + Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after")) + }) }) - It("never gives up if actual is a function", func() { - c := make(chan bool) - close(c) + Describe("The StopTrying signal", func() { + Context("when success occurs on the last iteration", func() { + It("succeeds and stops when the signal is returned", func() { + possibilities := []string{"A", "B", "C"} + i := 0 + Eventually(func() (string, error) { + possibility := possibilities[i] + i += 1 + if i == len(possibilities) { + return possibility, StopTrying("Reached the end") + } else { + return possibility, nil + } + }).Should(Equal("C")) + Ω(i).Should(Equal(3)) + }) + + It("counts as success for consistently", func() { + i := 0 + Consistently(func() (int, error) { + i += 1 + if i >= 10 { + return i, StopTrying("Reached the end") + } + return i, nil + }).Should(BeNumerically("<=", 10)) + + i = 0 + Consistently(func() int { + i += 1 + if i >= 10 { + StopTrying("Reached the end").Now() + } + return i + }).Should(BeNumerically("<=", 10)) + }) + }) + + Context("when success does not occur", func() { + It("fails and stops trying early", func() { + possibilities := []string{"A", "B", "C"} + i := 0 + ig.G.Eventually(func() (string, error) { + possibility := possibilities[i] + i += 1 + if i == len(possibilities) { + return possibility, StopTrying("Reached the end") + } else { + return possibility, nil + } + }).Should(Equal("D")) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Reached the end - after")) + Ω(ig.FailureMessage).Should(ContainSubstring("Expected\n : C\nto equal\n : D")) + }) + }) - t := time.Now() - ig.G.Eventually(func() chan bool { return c }).WithTimeout(100*time.Millisecond).WithPolling(10*time.Millisecond).Should(Receive(), "Receive is an OracleMatcher that gives up if the channel is closed") - Ω(time.Since(t)).Should(BeNumerically(">=", 90*time.Millisecond)) - Ω(ig.FailureMessage).ShouldNot(ContainSubstring("No future change is possible.")) - Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after")) + Context("when StopTrying().Now() is called", func() { + It("halts execution, stops trying, and emits the last failure", func() { + possibilities := []string{"A", "B", "C"} + i := -1 + ig.G.Eventually(func() string { + i += 1 + if i < len(possibilities) { + return possibilities[i] + } else { + StopTrying("Out of tries").Now() + panic("welp") + } + }).Should(Equal("D")) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Out of tries - after")) + Ω(ig.FailureMessage).Should(ContainSubstring("Expected\n : C\nto equal\n : D")) + }) + }) + + It("still allows regular panics to get through", func() { + defer func() { + e := recover() + Ω(e).Should(Equal("welp")) + }() + Eventually(func() string { + panic("welp") + return "A" + }).Should(Equal("A")) + }) + + Context("when used in conjunction wihth a Gomega and/or Context", func() { + It("correctly catches the StopTrying signal", func() { + i := 0 + ctx := context.WithValue(context.Background(), "key", "A") + ig.G.Eventually(func(g Gomega, ctx context.Context, expected string) { + i += 1 + if i >= 3 { + StopTrying("Out of tries").Now() + } + g.Expect(ctx.Value("key")).To(Equal(expected)) + }).WithContext(ctx).WithArguments("B").Should(Succeed()) + Ω(i).Should(Equal(3)) + Ω(ig.FailureMessage).Should(ContainSubstring("Out of tries - after")) + Ω(ig.FailureMessage).Should(ContainSubstring("Assertion in callback at")) + Ω(ig.FailureMessage).Should(ContainSubstring(": A")) + }) + }) }) })