diff --git a/docs/index.md b/docs/index.md index 0c351d5ac..edb667290 100644 --- a/docs/index.md +++ b/docs/index.md @@ -265,6 +265,8 @@ You can also configure the context in this way: Eventually(ACTUAL).WithTimeout(TIMEOUT).WithPolling(POLLING_INTERVAL).WithContext(ctx).Should(MATCHER) ``` +When no explicit timeout is provided, `Eventually` will use the default timeout. However if no explicit timeout is provided _and_ a context is provided, `Eventually` will not apply a timeout but will instead keep trying until the context is cancelled. If both a context and a timeout are provided, `Eventually` will keep trying until either the context is cancelled or time runs out, whichever comes first. + Eventually works with any Gomega compatible matcher and supports making assertions against three categories of `ACTUAL` value: #### Category 1: Making `Eventually` assertions on values @@ -475,6 +477,8 @@ As with `Eventually`, you can also pass `Consistently` a function. In fact, `Co If `Consistently` is passed a `context.Context` it will exit if the context is cancelled - however it will always register the cancellation of the context as a failure. That is, the context is not used to control the duration of `Consistently` - that is always done by the `DURATION` parameter; instead, the context is used to allow `Consistently` to bail out early if it's time for the spec to finish up (e.g. a timeout has elapsed, or the user has sent an interrupt signal). +When no explicit duration is provided, `Consistently` will use the default duration. Unlike `Eventually`, this behavior holds whether or not a context is provided. + > 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 diff --git a/internal/async_assertion.go b/internal/async_assertion.go index fcd295fab..818a6e198 100644 --- a/internal/async_assertion.go +++ b/internal/async_assertion.go @@ -2,12 +2,12 @@ package internal import ( "context" + "errors" "fmt" "reflect" "runtime" "sync" "time" - "errors" "github.com/onsi/gomega/types" ) @@ -18,7 +18,6 @@ type StopTryingError interface { wasViaPanic() bool } - func asStopTryingError(actual interface{}) (StopTryingError, bool) { if actual == nil { return nil, false @@ -173,7 +172,7 @@ func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (in return nil, fmt.Errorf("No values were returned by the function passed to Gomega"), stopTrying } actual := values[0].Interface() - if stopTryingErr, ok := asStopTryingError(actual); ok{ + if stopTryingErr, ok := asStopTryingError(actual); ok { stopTrying = stopTryingErr } for i, extraValue := range values[1:] { @@ -181,7 +180,7 @@ func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (in if extra == nil { continue } - if stopTryingErr, ok := asStopTryingError(extra); ok{ + if stopTryingErr, ok := asStopTryingError(extra); ok { stopTrying = stopTryingErr continue } @@ -325,13 +324,40 @@ func (assertion *AsyncAssertion) matcherSaysStopTrying(matcher types.GomegaMatch return StopTrying("No future change is possible. Bailing out early") } +func (assertion *AsyncAssertion) afterTimeout() <-chan time.Time { + if assertion.timeoutInterval >= 0 { + return time.After(assertion.timeoutInterval) + } + + if assertion.asyncType == AsyncAssertionTypeConsistently { + return time.After(assertion.g.DurationBundle.ConsistentlyDuration) + } else { + if assertion.ctx == nil { + return time.After(assertion.g.DurationBundle.EventuallyTimeout) + } else { + return nil + } + } +} + +func (assertion *AsyncAssertion) afterPolling() <-chan time.Time { + if assertion.pollingInterval >= 0 { + return time.After(assertion.pollingInterval) + } + if assertion.asyncType == AsyncAssertionTypeConsistently { + return time.After(assertion.g.DurationBundle.ConsistentlyPollingInterval) + } else { + return time.After(assertion.g.DurationBundle.EventuallyPollingInterval) + } +} + type contextWithAttachProgressReporter interface { AttachProgressReporter(func() string) func() } func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch bool, optionalDescription ...interface{}) bool { timer := time.Now() - timeout := time.After(assertion.timeoutInterval) + timeout := assertion.afterTimeout() lock := sync.Mutex{} var matches bool @@ -398,7 +424,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch } select { - case <-time.After(assertion.pollingInterval): + case <-assertion.afterPolling(): 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 @@ -438,7 +464,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch } select { - case <-time.After(assertion.pollingInterval): + case <-assertion.afterPolling(): 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 diff --git a/internal/async_assertion_test.go b/internal/async_assertion_test.go index c0548c29a..2fffd6b1a 100644 --- a/internal/async_assertion_test.go +++ b/internal/async_assertion_test.go @@ -170,70 +170,108 @@ var _ = Describe("Asynchronous Assertions", func() { }) }) - Context("when the passed-in context is cancelled", func() { - It("stops and returns a failure", func() { - ctx, cancel := context.WithCancel(context.Background()) - counter := 0 - ig.G.Eventually(func() string { - counter++ - if counter == 2 { - cancel() - } else if counter == 10 { - return MATCH - } - return NO_MATCH - }, time.Hour, ctx).Should(SpecMatch()) - Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) - Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match")) - }) + Context("with a passed-in context", func() { + Context("when the passed-in context is cancelled", func() { + It("stops and returns a failure", func() { + ctx, cancel := context.WithCancel(context.Background()) + counter := 0 + ig.G.Eventually(func() string { + counter++ + if counter == 2 { + cancel() + } else if counter == 10 { + return MATCH + } + return NO_MATCH + }, time.Hour, ctx).Should(SpecMatch()) + Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) + Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match")) + }) - It("can also be configured via WithContext()", func() { - ctx, cancel := context.WithCancel(context.Background()) - counter := 0 - ig.G.Eventually(func() string { - counter++ - if counter == 2 { - cancel() - } else if counter == 10 { + It("can also be configured via WithContext()", func() { + ctx, cancel := context.WithCancel(context.Background()) + counter := 0 + ig.G.Eventually(func() string { + counter++ + if counter == 2 { + cancel() + } else if counter == 10 { + return MATCH + } + return NO_MATCH + }, time.Hour).WithContext(ctx).Should(SpecMatch()) + Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) + Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match")) + }) + + It("counts as a failure for Consistently", func() { + ctx, cancel := context.WithCancel(context.Background()) + counter := 0 + ig.G.Consistently(func() string { + counter++ + if counter == 2 { + cancel() + } else if counter == 10 { + return NO_MATCH + } return MATCH - } - return NO_MATCH - }, time.Hour).WithContext(ctx).Should(SpecMatch()) - Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) - Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match")) + }, time.Hour).WithContext(ctx).Should(SpecMatch()) + Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) + Ω(ig.FailureMessage).Should(ContainSubstring("positive: match")) + }) }) - It("counts as a failure for Consistently", func() { - ctx, cancel := context.WithCancel(context.Background()) - counter := 0 - ig.G.Consistently(func() string { - counter++ - if counter == 2 { - cancel() - } else if counter == 10 { + Context("when the passed-in context is a Ginkgo SpecContext that can take a progress reporter attachment", func() { + It("attaches a progress reporter context that allows it to report on demand", func() { + fakeSpecContext := &FakeGinkgoSpecContext{} + var message string + ctx := context.WithValue(context.Background(), "GINKGO_SPEC_CONTEXT", fakeSpecContext) + ig.G.Eventually(func() string { + if fakeSpecContext.Attached != nil { + message = fakeSpecContext.Attached() + } return NO_MATCH - } - return MATCH - }, time.Hour).WithContext(ctx).Should(SpecMatch()) - Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) - Ω(ig.FailureMessage).Should(ContainSubstring("positive: match")) + }).WithTimeout(time.Millisecond * 20).WithContext(ctx).Should(Equal(MATCH)) + + Ω(message).Should(Equal("Expected\n : no match\nto equal\n : match")) + Ω(fakeSpecContext.Cancelled).Should(BeTrue()) + }) }) - }) - Context("when the passed-in context is a Ginkgo SpecContext that can take a progress reporter attachment", func() { - It("attaches a progress reporter context that allows it to report on demand", func() { - fakeSpecContext := &FakeGinkgoSpecContext{} - var message string - ctx := context.WithValue(context.Background(), "GINKGO_SPEC_CONTEXT", fakeSpecContext) - ig.G.Eventually(func() string { - if fakeSpecContext.Attached != nil { - message = fakeSpecContext.Attached() - } - return NO_MATCH - }).WithTimeout(time.Millisecond * 20).WithContext(ctx).Should(Equal(MATCH)) + Describe("the interaction between the context and the timeout", func() { + It("only relies on context cancellation when no explicit timeout is specified", func() { + ig.G.SetDefaultEventuallyTimeout(time.Millisecond * 10) + ig.G.SetDefaultEventuallyPollingInterval(time.Millisecond * 40) + t := time.Now() + ctx, cancel := context.WithCancel(context.Background()) + iterations := 0 + ig.G.Eventually(func() string { + iterations += 1 + if time.Since(t) > time.Millisecond*200 { + cancel() + } + return "A" + }).WithContext(ctx).Should(Equal("B")) + Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*100)) + Ω(iterations).Should(BeNumerically("~", 200/40, 2)) + Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) + }) - Ω(message).Should(Equal("Expected\n : no match\nto equal\n : match")) - Ω(fakeSpecContext.Cancelled).Should(BeTrue()) + It("uses the explicit timeout when it is provided", func() { + t := time.Now() + ctx, cancel := context.WithCancel(context.Background()) + iterations := 0 + ig.G.Eventually(func() string { + iterations += 1 + if time.Since(t) > time.Millisecond*200 { + cancel() + } + return "A" + }).WithContext(ctx).WithTimeout(time.Millisecond * 80).ProbeEvery(time.Millisecond * 40).Should(Equal("B")) + Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*80, time.Millisecond*40)) + Ω(iterations).Should(BeNumerically("~", 80/40, 2)) + Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after")) + }) }) }) }) @@ -352,6 +390,44 @@ var _ = Describe("Asynchronous Assertions", func() { Ω(ig.FailureMessage).Should(ContainSubstring("boop")) }) }) + + Context("with a passed-in context", func() { + Context("when the passed-in context is cancelled", func() { + It("counts as a failure for Consistently", func() { + ctx, cancel := context.WithCancel(context.Background()) + counter := 0 + ig.G.Consistently(func() string { + counter++ + if counter == 2 { + cancel() + } else if counter == 10 { + return NO_MATCH + } + return MATCH + }, time.Hour).WithContext(ctx).Should(SpecMatch()) + Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after")) + Ω(ig.FailureMessage).Should(ContainSubstring("positive: match")) + }) + }) + + Describe("the interaction between the context and the timeout", func() { + It("only always uses the default interval even if not explicit duration is provided", func() { + ig.G.SetDefaultConsistentlyDuration(time.Millisecond * 200) + ig.G.SetDefaultConsistentlyPollingInterval(time.Millisecond * 40) + t := time.Now() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + iterations := 0 + ig.G.Consistently(func() string { + iterations += 1 + return "A" + }).WithContext(ctx).Should(Equal("A")) + Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*100)) + Ω(iterations).Should(BeNumerically("~", 200/40, 2)) + Ω(ig.FailureMessage).Should(BeZero()) + }) + }) + }) }) Describe("the passed-in actual", func() { diff --git a/internal/gomega.go b/internal/gomega.go index 52a6b243c..2d67f7f1c 100644 --- a/internal/gomega.go +++ b/internal/gomega.go @@ -57,8 +57,8 @@ func (g *Gomega) Eventually(actual interface{}, intervals ...interface{}) types. } func (g *Gomega) EventuallyWithOffset(offset int, actual interface{}, args ...interface{}) types.AsyncAssertion { - timeoutInterval := g.DurationBundle.EventuallyTimeout - pollingInterval := g.DurationBundle.EventuallyPollingInterval + timeoutInterval := -time.Duration(1) + pollingInterval := -time.Duration(1) intervals := []interface{}{} var ctx context.Context for _, arg := range args { @@ -84,8 +84,8 @@ func (g *Gomega) Consistently(actual interface{}, intervals ...interface{}) type } func (g *Gomega) ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{}) types.AsyncAssertion { - timeoutInterval := g.DurationBundle.ConsistentlyDuration - pollingInterval := g.DurationBundle.ConsistentlyPollingInterval + timeoutInterval := -time.Duration(1) + pollingInterval := -time.Duration(1) intervals := []interface{}{} var ctx context.Context for _, arg := range args {