Skip to content

Commit

Permalink
Eventually and Consistently can take a context.Context
Browse files Browse the repository at this point in the history
  • Loading branch information
onsi committed Oct 6, 2022
1 parent 12469a0 commit 65c01bc
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 31 deletions.
54 changes: 45 additions & 9 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ Gomega has support for making *asynchronous* assertions. There are two function
`Eventually` checks that an assertion *eventually* passes. `Eventually` blocks when called and attempts an assertion periodically until it passes or a timeout occurs. Both the timeout and polling interval are configurable as optional arguments:

```go
Eventually(ACTUAL, (TIMEOUT), (POLLING_INTERVAL)).Should(MATCHER)
Eventually(ACTUAL, (TIMEOUT), (POLLING_INTERVAL), (context.Context).Should(MATCHER)
```

The first optional argument is the timeout (which defaults to 1s), the second is the polling interval (which defaults to 10ms). Both intervals can be specified as time.Duration, parsable duration strings (e.g. "100ms") or `float64` (in which case they are interpreted as seconds).
The first optional argument is the timeout (which defaults to 1s), the second is the polling interval (which defaults to 10ms). Both intervals can be specified as time.Duration, parsable duration strings (e.g. "100ms") or `float64` (in which case they are interpreted as seconds). You can also provide a `context.Context` which - when cancelled - will instruct `Eventually` to stop and exit with a failure message.

> As with synchronous assertions, you can annotate asynchronous assertions by passing either a format string and optional inputs or a function of type `func() string` after the `GomegaMatcher`.
Expand All @@ -259,6 +259,12 @@ Eventually(ACTUAL).WithTimeout(TIMEOUT).WithPolling(POLLING_INTERVAL).Should(MAT
Eventually(ACTUAL).Within(TIMEOUT).ProbeEvery(POLLING_INTERVAL).Should(MATCHER)
```

You can also configure the context in this way:

```go
Eventually(ACTUAL).WithTimeout(TIMEOUT).WithPolling(POLLING_INTERVAL).WithContext(ctx).Should(MATCHER)
```

Eventually works with any Gomega compatible matcher and supports making assertions against three categories of `ACTUAL` value:

#### Category 1: Making `Eventually` assertions on values
Expand Down Expand Up @@ -332,7 +338,36 @@ Eventually(FetchFromDB).Should(Equal("got it"))

will pass only if and when the returned error is `nil` *and* the returned string satisfies the matcher.

It is important to note that the function passed into Eventually is invoked **synchronously** when polled. `Eventually` does not (in fact, it cannot) kill the function if it takes longer to return than `Eventually`'s configured timeout. You should design your functions with this in mind.
It is important to note that the function passed into Eventually is invoked **synchronously** when polled. `Eventually` does not (in fact, it cannot) kill the function if it takes longer to return than `Eventually`'s configured timeout. This is where using a `context.Context` can be helpful. Here is an example that leverages Gingko's support for interruptible nodes and spec timeouts:

```go
It("fetches the correct count", func(ctx SpecContext) {
Eventually(func() int {
return client.FetchCount(ctx)
}, ctx).Should(BeNumerically(">=", 17))
}, SpecTimeout(time.Second))
```

now when the spec times out both the `client.FetchCount` function and `Eventually` will be signaled and told to exit.

The use of a context also allows you to specify a single timeout across a collection of `Eventually` assertions:

```go
It("adds a few books and checks the count", func(ctx SpecContext) {
intialCount := client.FetchCount(ctx)
client.AddItem(ctx, "foo")
client.AddItem(ctx, "bar")
Eventually(func() {
return client.FetchCount(ctx)
}).WithContext(ctx).Should(BeNumerically(">=", 17))
Eventually(func() {
return client.FetchItems(ctx)
}).WithContext(ctx).Should(ContainElement("foo"))
Eventually(func() {
return client.FetchItems(ctx)
}).WithContext(ctx).Should(ContainElement("bar"))
}, SpecTimeout(time.Second * 5))
```

#### Category 3: Making assertions _in_ the function passed into `Eventually`

Expand Down Expand Up @@ -367,7 +402,6 @@ Eventually(func(g Gomega) {

will rerun the function until all assertions pass.


### Consistently

`Consistently` checks that an assertion passes for a period of time. It does this by polling its argument repeatedly during the period. It fails if the matcher ever fails during that period.
Expand All @@ -380,18 +414,18 @@ Consistently(func() []int {
}).Should(BeNumerically("<", 10))
```

`Consistently` will poll the passed in function repeatedly and check the return value against the `GomegaMatcher`. `Consistently` blocks and only returns when the desired duration has elapsed or if the matcher fails. The default value for the wait-duration is 100 milliseconds. The default polling interval is 10 milliseconds. Like `Eventually`, you can change these values by passing them in just after your function:
`Consistently` will poll the passed in function repeatedly and check the return value against the `GomegaMatcher`. `Consistently` blocks and only returns when the desired duration has elapsed or if the matcher fails or if an (optional) passed-in context is cancelled. The default value for the wait-duration is 100 milliseconds. The default polling interval is 10 milliseconds. Like `Eventually`, you can change these values by passing them in just after your function:

```go
Consistently(ACTUAL, DURATION, POLLING_INTERVAL).Should(MATCHER)
Consistently(ACTUAL, (DURATION), (POLLING_INTERVAL), (context.Context)).Should(MATCHER)
```

As with `Eventually`, these can be `time.Duration`s, string representations of a `time.Duration` (e.g. `"200ms"`) or `float64`s that are interpreted as seconds.
As with `Eventually`, the duration parameters can be `time.Duration`s, string representations of a `time.Duration` (e.g. `"200ms"`) or `float64`s that are interpreted as seconds.

Also as with `Eventually`, `Consistently` supports chaining `WithTimeout` and `WithPolling` in the form of:
Also as with `Eventually`, `Consistently` supports chaining `WithTimeout` and `WithPolling` and `WithContext` in the form of:

```go
Consistently(ACTUAL).WithTimeout(DURATION).WithPolling(POLLING_INTERVAL).Should(MATCHER)
Consistently(ACTUAL).WithTimeout(DURATION).WithPolling(POLLING_INTERVAL).WithContext(ctx).Should(MATCHER)
```

`Consistently` tries to capture the notion that something "does not eventually" happen. A common use-case is to assert that no goroutine writes to a channel for a period of time. If you pass `Consistently` an argument that is not a function, it simply passes that argument to the matcher. So we can assert that:
Expand All @@ -404,6 +438,8 @@ To assert that nothing gets sent to a channel.

As with `Eventually`, you can also pass `Consistently` a function. In fact, `Consistently` works with the three categories of `ACTUAL` value outlined for `Eventually` in the section above.

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).

> 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!
### Modifying Default Intervals
Expand Down
39 changes: 26 additions & 13 deletions gomega_dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ func ExpectWithOffset(offset int, actual interface{}, extra ...interface{}) Asse
Eventually enables making assertions on asynchronous behavior.
Eventually checks that an assertion *eventually* passes. Eventually blocks when called and attempts an assertion periodically until it passes or a timeout occurs. Both the timeout and polling interval are configurable as optional arguments.
The first optional argument is the timeout (which defaults to 1s), the second is the polling interval (which defaults to 10ms). Both intervals can be specified as time.Duration, parsable duration strings or floats/integers (in which case they are interpreted as seconds).
The first optional argument is the timeout (which defaults to 1s), the second is the polling interval (which defaults to 10ms). Both intervals can be specified as time.Duration, parsable duration strings or floats/integers (in which case they are interpreted as seconds). In addition an optional context.Context can be passed in - Eventually will keep trying until either the timeout epxires or the context is cancelled, whichever comes first.
Eventually works with any Gomega compatible matcher and supports making assertions against three categories of actual value:
Expand Down Expand Up @@ -286,7 +286,15 @@ Then
will pass only if and when the returned error is nil *and* the returned string satisfies the matcher.
It is important to note that the function passed into Eventually is invoked *synchronously* when polled. Eventually does not (in fact, it cannot) kill the function if it takes longer to return than Eventually's configured timeout. You should design your functions with this in mind.
It is important to note that the function passed into Eventually is invoked *synchronously* when polled. Eventually does not (in fact, it cannot) kill the function if it takes longer to return than Eventually's configured timeout. A common practice here is to use a context. Here's an example that combines Ginkgo's spec timeout support with Eventually:
It("fetches the correct count", func(ctx SpecContext) {
Eventually(func() int {
return client.FetchCount(ctx)
}, ctx).Should(BeNumerically(">=", 17))
}, SpecTimeout(time.Second))
now, when Ginkgo cancels the context both the FetchCount client and Gomega will be informed and can exit.
**Category 3: Making assertions _in_ the function passed into Eventually**
Expand Down Expand Up @@ -316,12 +324,17 @@ For example:
will rerun the function until all assertions pass.
`Eventually` specifying a timeout interval (and an optional polling interval) are
the same as `Eventually(...).WithTimeout` or `Eventually(...).WithTimeout(...).WithPolling`.
Finally, in addition to passing timeouts and a context to Eventually you can be more explicit with Eventually's chaining configuration methods:
Eventually(..., "1s", "2s", ctx).Should(...)
is equivalent to
Eventually(...).WithTimeout(time.Second).WithPolling(2*time.Second).WithContext(ctx).Should(...)
*/
func Eventually(actual interface{}, intervals ...interface{}) AsyncAssertion {
func Eventually(actual interface{}, args ...interface{}) AsyncAssertion {
ensureDefaultGomegaIsConfigured()
return Default.Eventually(actual, intervals...)
return Default.Eventually(actual, args...)
}

// EventuallyWithOffset operates like Eventually but takes an additional
Expand All @@ -333,17 +346,17 @@ func Eventually(actual interface{}, intervals ...interface{}) AsyncAssertion {
// `EventuallyWithOffset` specifying a timeout interval (and an optional polling interval) are
// the same as `Eventually(...).WithOffset(...).WithTimeout` or
// `Eventually(...).WithOffset(...).WithTimeout(...).WithPolling`.
func EventuallyWithOffset(offset int, actual interface{}, intervals ...interface{}) AsyncAssertion {
func EventuallyWithOffset(offset int, actual interface{}, args ...interface{}) AsyncAssertion {
ensureDefaultGomegaIsConfigured()
return Default.EventuallyWithOffset(offset, actual, intervals...)
return Default.EventuallyWithOffset(offset, actual, args...)
}

/*
Consistently, like Eventually, enables making assertions on asynchronous behavior.
Consistently blocks when called for a specified duration. During that duration Consistently repeatedly polls its matcher and ensures that it is satisfied. If the matcher is consistently satisfied, then Consistently will pass. Otherwise Consistently will fail.
Both the total waiting duration and the polling interval are configurable as optional arguments. The first optional argument is the duration that Consistently will run for (defaults to 100ms), and the second argument is the polling interval (defaults to 10ms). As with Eventually, these intervals can be passed in as time.Duration, parsable duration strings or an integer or float number of seconds.
Both the total waiting duration and the polling interval are configurable as optional arguments. The first optional argument is the duration that Consistently will run for (defaults to 100ms), and the second argument is the polling interval (defaults to 10ms). As with Eventually, these intervals can be passed in as time.Duration, parsable duration strings or an integer or float number of seconds. You can also pass in an optional context.Context - Consistently will exit early (with a failure) if the context is cancelled before the waiting duration expires.
Consistently accepts the same three categories of actual as Eventually, check the Eventually docs to learn more.
Expand All @@ -353,9 +366,9 @@ Consistently is useful in cases where you want to assert that something *does no
This will block for 200 milliseconds and repeatedly check the channel and ensure nothing has been received.
*/
func Consistently(actual interface{}, intervals ...interface{}) AsyncAssertion {
func Consistently(actual interface{}, args ...interface{}) AsyncAssertion {
ensureDefaultGomegaIsConfigured()
return Default.Consistently(actual, intervals...)
return Default.Consistently(actual, args...)
}

// ConsistentlyWithOffset operates like Consistently but takes an additional
Expand All @@ -364,9 +377,9 @@ func Consistently(actual interface{}, intervals ...interface{}) AsyncAssertion {
//
// `ConsistentlyWithOffset` is the same as `Consistently(...).WithOffset` and
// optional `WithTimeout` and `WithPolling`.
func ConsistentlyWithOffset(offset int, actual interface{}, intervals ...interface{}) AsyncAssertion {
func ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{}) AsyncAssertion {
ensureDefaultGomegaIsConfigured()
return Default.ConsistentlyWithOffset(offset, actual, intervals...)
return Default.ConsistentlyWithOffset(offset, actual, args...)
}

// SetDefaultEventuallyTimeout sets the default timeout duration for Eventually. Eventually will repeatedly poll your condition until it succeeds, or until this timeout elapses.
Expand Down
21 changes: 20 additions & 1 deletion internal/async_assertion.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package internal

import (
"context"
"errors"
"fmt"
"reflect"
Expand All @@ -26,16 +27,18 @@ type AsyncAssertion struct {

timeoutInterval time.Duration
pollingInterval time.Duration
ctx context.Context
offset int
g *Gomega
}

func NewAsyncAssertion(asyncType AsyncAssertionType, actualInput interface{}, g *Gomega, timeoutInterval time.Duration, pollingInterval time.Duration, offset int) *AsyncAssertion {
func NewAsyncAssertion(asyncType AsyncAssertionType, actualInput interface{}, g *Gomega, timeoutInterval time.Duration, pollingInterval time.Duration, ctx context.Context, offset int) *AsyncAssertion {
out := &AsyncAssertion{
asyncType: asyncType,
timeoutInterval: timeoutInterval,
pollingInterval: pollingInterval,
offset: offset,
ctx: ctx,
g: g,
}

Expand Down Expand Up @@ -112,6 +115,11 @@ func (assertion *AsyncAssertion) ProbeEvery(interval time.Duration) types.AsyncA
return assertion
}

func (assertion *AsyncAssertion) WithContext(ctx context.Context) types.AsyncAssertion {
assertion.ctx = ctx
return assertion
}

func (assertion *AsyncAssertion) Should(matcher types.GomegaMatcher, optionalDescription ...interface{}) bool {
assertion.g.THelper()
vetOptionalDescription("Asynchronous assertion", optionalDescription...)
Expand Down Expand Up @@ -196,6 +204,11 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
assertion.g.Fail(fmt.Sprintf("%s after %.3fs.\n%s%s%s", preamble, time.Since(timer).Seconds(), description, message, errMsg), 3+assertion.offset)
}

var contextDone <-chan struct{}
if assertion.ctx != nil {
contextDone = assertion.ctx.Done()
}

if assertion.asyncType == AsyncAssertionTypeEventually {
for {
if err == nil && matches == desiredMatch {
Expand All @@ -214,6 +227,9 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
mayChange = assertion.matcherMayChange(matcher, value)
matches, err = matcher.Match(value)
}
case <-contextDone:
fail("Context was cancelled")
return false
case <-timeout:
fail("Timed out")
return false
Expand All @@ -237,6 +253,9 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
mayChange = assertion.matcherMayChange(matcher, value)
matches, err = matcher.Match(value)
}
case <-contextDone:
fail("Context was cancelled")
return false
case <-timeout:
return true
}
Expand Down
55 changes: 51 additions & 4 deletions internal/async_assertion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/net/context"
)

var _ = Describe("Asynchronous Assertions", func() {
Expand Down Expand Up @@ -156,6 +157,56 @@ var _ = Describe("Asynchronous Assertions", func() {
Ω(ig.FailureMessage).Should(ContainSubstring("boop"))
})
})

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 {
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
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: match"))
})
})
})

Describe("Basic Consistently support", func() {
Expand Down Expand Up @@ -735,7 +786,6 @@ var _ = Describe("Asynchronous Assertions", func() {
})

When("vetting optional description parameters", func() {

It("panics when Gomega matcher is at the beginning of optional description parameters", func() {
ig := NewInstrumentedGomega()
for _, expectator := range []string{
Expand Down Expand Up @@ -763,7 +813,6 @@ var _ = Describe("Asynchronous Assertions", func() {
})

Context("eventual nil-ism", func() { // issue #555

It("doesn't panic on nil actual", func() {
ig := NewInstrumentedGomega()
Expect(func() {
Expand All @@ -777,7 +826,5 @@ var _ = Describe("Asynchronous Assertions", func() {
ig.G.Eventually(func() error { return nil }).Should(BeNil())
}).NotTo(Panic())
})

})

})
Loading

0 comments on commit 65c01bc

Please sign in to comment.