Skip to content

Commit

Permalink
Gomega supports passing arguments to functions via WithArguments()
Browse files Browse the repository at this point in the history
  • Loading branch information
onsi committed Oct 10, 2022
1 parent e2091c5 commit a2dc7c3
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 43 deletions.
67 changes: 50 additions & 17 deletions docs/index.md
Expand Up @@ -308,7 +308,7 @@ In both cases you should always pass `Eventually` a function that, when polled,

#### Category 2: Making `Eventually` assertions on functions

`Eventually` can be passed functions that **take no arguments** and **return at least one value**. When configured this way, `Eventually` will poll the function repeatedly and pass the first returned value to the matcher.
`Eventually` can be passed functions that **return at least one value**. When configured this way, `Eventually` will poll the function repeatedly and pass the first returned value to the matcher.

For example:

Expand All @@ -322,7 +322,7 @@ will repeatedly poll `client.FetchCount` until the `BeNumerically` matcher is sa

> Note that this example could have been written as `Eventually(client.FetchCount).Should(BeNumerically(">=", 17))`
If multple values are returned by the function, `Eventually` will pass the first value to the matcher and require that all others are zero-valued. This allows you to pass `Eventually` a function that returns a value and an error - a common pattern in Go.
If multiple values are returned by the function, `Eventually` will pass the first value to the matcher and require that all others are zero-valued. This allows you to pass `Eventually` a function that returns a value and an error - a common pattern in Go.

For example, consider a method that returns a value and an error:

Expand All @@ -338,38 +338,58 @@ Eventually(FetchFromDB).Should(Equal("got it"))

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


Eventually can also accept functions that take arguments, however you must provide those arguments using `Eventually().WithArguments()`. For example, consider a function that takes a user-id and makes a network request to fetch a full name:

```go
func FetchFullName(userId int) (string, error)
```

You can poll this function like so:

```go
Eventually(FetchFullName).WithArguments(1138).Should(Equal("Wookie"))
```

`WithArguments()` supports multiple arugments as well as variadic arguments.

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)
return client.FetchCount(ctx, "/users")
}, 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.
now when the spec times out both the `client.FetchCount` function and `Eventually` will be signaled and told to exit. you an also use `Eventually().WithContext(ctx)` to provide the context.


Since functions that take a context.Context as a first-argument are common in Go, `Eventually` supports automatically injecting the provided context into the function. This plays nicely with `WithArguments()` as well. You can rewrite the above example as:

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

now the `ctx` `SpecContext` is used both by `Eventually` and `client.FetchCount` and the `"/users"` argument is passed in after the `ctx` argument.

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)
intialCount := client.FetchCount(ctx, "/items")
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"))
Eventually(client.FetchCount).WithContext(ctx).WithArguments("/items").Should(BeNumerically("==", initialCount + 2))
Eventually(client.FetchItems).WithContext(ctx).Should(ContainElement("foo"))
Eventually(client.FetchItems).WithContext(ctx).Should(ContainElement("foo"))
}, SpecTimeout(time.Second * 5))
```

In addition, Gingko's `SpecContext` allows Goemga to tell Ginkgo about the status of a currently running `Eventually` whenever a Progress Report is generated. So, if a spec times out while running an `Eventually` Ginkgo will not only show you which `Eventually` was running when the timeout occured, but will also include the failure the `Eventually` was hitting when the timeout occurred.
In addition, Gingko's `SpecContext` allows Gomega to tell Ginkgo about the status of a currently running `Eventually` whenever a Progress Report is generated. So, if a spec times out while running an `Eventually` Ginkgo will not only show you which `Eventually` was running when the timeout occured, but will also include the failure the `Eventually` was hitting when the timeout occurred.

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

Expand Down Expand Up @@ -404,6 +424,19 @@ Eventually(func(g Gomega) {

will rerun the function until all assertions pass.

You can also pass additional arugments to functions that take a Gomega. The only rule is that the Gomega argument must be first. If you also want to pass the context attached to `Eventually` you must ensure that is the second argument. For example:

```go
Eventually(func(g Gomega, ctx context.Context, path string, expected ...string){
tok, err := client.GetToken(ctx)
g.Expect(err).NotTo(HaveOccurred())

elements, err := client.Fetch(ctx, tok, path)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(elements).To(ConsistOf(expected))
}).WithContext(ctx).WithArguments("/names", "Joe", "Jane", "Sam").Should(Succeed())
```

### 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 @@ -424,10 +457,10 @@ Consistently(ACTUAL, (DURATION), (POLLING_INTERVAL), (context.Context)).Should(M

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` and `WithContext` in the form of:
Also as with `Eventually`, `Consistently` supports chaining `WithTimeout`, `WithPolling`, `WithContext` and `WithArguments` in the form of:

```go
Consistently(ACTUAL).WithTimeout(DURATION).WithPolling(POLLING_INTERVAL).WithContext(ctx).Should(MATCHER)
Consistently(ACTUAL).WithTimeout(DURATION).WithPolling(POLLING_INTERVAL).WithContext(ctx).WithArguments(...).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 Down
29 changes: 26 additions & 3 deletions gomega_dsl.go
Expand Up @@ -266,7 +266,7 @@ this will trigger Go's race detector as the goroutine polling via Eventually wil
**Category 2: Make Eventually assertions on functions**
Eventually can be passed functions that **take no arguments** and **return at least one value**. When configured this way, Eventually will poll the function repeatedly and pass the first returned value to the matcher.
Eventually can be passed functions that **return at least one value**. When configured this way, Eventually will poll the function repeatedly and pass the first returned value to the matcher.
For example:
Expand All @@ -286,15 +286,27 @@ Then
will pass only if and when the returned error is nil *and* the returned string satisfies the matcher.
Eventually can also accept functions that take arguments, however you must provide those arguments using .WithArguments(). For example, consider a function that takes a user-id and makes a network request to fetch a full name:
func FetchFullName(userId int) (string, error)
You can poll this function like so:
Eventually(FetchFullName).WithArguments(1138).Should(Equal("Wookie"))
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)
return client.FetchCount(ctx, "/users")
}, 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.
you an also use Eventually().WithContext(ctx) to pass in the context. Passed-in contexts play nicely with paseed-in arguments as long as the context appears first. You can rewrite the above example as:
It("fetches the correct count", func(ctx SpecContext) {
Eventually(client.FetchCount).WithContext(ctx).WithArguments("/users").Should(BeNumerically(">=", 17))
}, SpecTimeout(time.Second))
Either way the context passd to Eventually is also passed to the underlying funciton. 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 @@ -324,6 +336,17 @@ For example:
will rerun the function until all assertions pass.
You can also pass additional arugments to functions that take a Gomega. The only rule is that the Gomega argument must be first. If you also want to pass the context attached to Eventually you must ensure that is the second argument. For example:
Eventually(func(g Gomega, ctx context.Context, path string, expected ...string){
tok, err := client.GetToken(ctx)
g.Expect(err).NotTo(HaveOccurred())
elements, err := client.Fetch(ctx, tok, path)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(elements).To(ConsistOf(expected))
}).WithContext(ctx).WithArguments("/names", "Joe", "Jane", "Sam").Should(Succeed())
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(...)
Expand Down
53 changes: 36 additions & 17 deletions internal/async_assertion.go
Expand Up @@ -31,8 +31,9 @@ func (at AsyncAssertionType) String() string {
type AsyncAssertion struct {
asyncType AsyncAssertionType

actualIsFunc bool
actual interface{}
actualIsFunc bool
actual interface{}
argsToForward []interface{}

timeoutInterval time.Duration
pollingInterval time.Duration
Expand Down Expand Up @@ -89,6 +90,11 @@ func (assertion *AsyncAssertion) WithContext(ctx context.Context) types.AsyncAss
return assertion
}

func (assertion *AsyncAssertion) WithArguments(argsToForward ...interface{}) types.AsyncAssertion {
assertion.argsToForward = argsToForward
return assertion
}

func (assertion *AsyncAssertion) Should(matcher types.GomegaMatcher, optionalDescription ...interface{}) bool {
assertion.g.THelper()
vetOptionalDescription("Asynchronous assertion", optionalDescription...)
Expand Down Expand Up @@ -145,11 +151,22 @@ You can learn more at https://onsi.github.io/gomega/#eventually
`, assertion.asyncType, t, assertion.asyncType)
}

func (assertion *AsyncAssertion) noConfiguredContextForFunctionError(t reflect.Type) error {
return fmt.Errorf(`The function passed to %s requested a context.Context, but no context has been provided to %s. Please pass one in using %s().WithContext().
func (assertion *AsyncAssertion) noConfiguredContextForFunctionError() error {
return fmt.Errorf(`The function passed to %s requested a context.Context, but no context has been provided. Please pass one in using %s().WithContext().
You can learn more at https://onsi.github.io/gomega/#eventually
`, assertion.asyncType, t, assertion.asyncType)
`, assertion.asyncType, assertion.asyncType)
}

func (assertion *AsyncAssertion) argumentMismatchError(t reflect.Type, numProvided int) error {
have := "have"
if numProvided == 1 {
have = "has"
}
return fmt.Errorf(`The function passed to %s has signature %s takes %d arguments but %d %s been provided. Please use %s().WithArguments() to pass the corect set of arguments.
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) {
Expand All @@ -158,7 +175,7 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error
}
actualValue := reflect.ValueOf(assertion.actual)
actualType := reflect.TypeOf(assertion.actual)
numIn, numOut := actualType.NumIn(), actualType.NumOut()
numIn, numOut, isVariadic := actualType.NumIn(), actualType.NumOut(), actualType.IsVariadic()

if numIn == 0 && numOut == 0 {
return nil, assertion.invalidFunctionError(actualType)
Expand All @@ -169,21 +186,14 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error
if takesGomega && numIn > 1 && actualType.In(1).Implements(contextType) {
takesContext = true
}
if takesContext && len(assertion.argsToForward) > 0 && reflect.TypeOf(assertion.argsToForward[0]).Implements(contextType) {
takesContext = false
}
if !takesGomega && numOut == 0 {
return nil, assertion.invalidFunctionError(actualType)
}
if takesContext && assertion.ctx == nil {
return nil, assertion.noConfiguredContextForFunctionError(actualType)
}
remainingIn := numIn
if takesGomega {
remainingIn -= 1
}
if takesContext {
remainingIn -= 1
}
if remainingIn > 0 {
return nil, assertion.invalidFunctionError(actualType)
return nil, assertion.noConfiguredContextForFunctionError()
}

var assertionFailure error
Expand All @@ -202,6 +212,15 @@ func (assertion *AsyncAssertion) buildActualPoller() (func() (interface{}, error
if takesContext {
inValues = append(inValues, reflect.ValueOf(assertion.ctx))
}
for _, arg := range assertion.argsToForward {
inValues = append(inValues, reflect.ValueOf(arg))
}

if !isVariadic && numIn != len(inValues) {
return nil, assertion.argumentMismatchError(actualType, len(inValues))
} else if isVariadic && len(inValues) < numIn-1 {
return nil, assertion.argumentMismatchError(actualType, len(inValues))
}

return func() (actual interface{}, err error) {
var values []reflect.Value
Expand Down

0 comments on commit a2dc7c3

Please sign in to comment.