From a2dc7c3d26f23a96c27441f3ae196d5f053a624f Mon Sep 17 00:00:00 2001 From: Onsi Fakhouri Date: Mon, 10 Oct 2022 15:18:41 -0600 Subject: [PATCH] Gomega supports passing arguments to functions via WithArguments() --- docs/index.md | 67 ++++++++++++++++------ gomega_dsl.go | 29 +++++++++- internal/async_assertion.go | 53 +++++++++++------ internal/async_assertion_test.go | 97 ++++++++++++++++++++++++++++++-- types/types.go | 1 + 5 files changed, 204 insertions(+), 43 deletions(-) diff --git a/docs/index.md b/docs/index.md index 84db222ad..42f99f65d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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: @@ -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: @@ -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` @@ -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. @@ -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: diff --git a/gomega_dsl.go b/gomega_dsl.go index 274116fd4..5f7158813 100644 --- a/gomega_dsl.go +++ b/gomega_dsl.go @@ -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: @@ -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** @@ -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(...) diff --git a/internal/async_assertion.go b/internal/async_assertion.go index 5674889a8..ed09527e2 100644 --- a/internal/async_assertion.go +++ b/internal/async_assertion.go @@ -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 @@ -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...) @@ -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) { @@ -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) @@ -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 @@ -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 diff --git a/internal/async_assertion_test.go b/internal/async_assertion_test.go index bad53dacc..71f12c835 100644 --- a/internal/async_assertion_test.go +++ b/internal/async_assertion_test.go @@ -2,8 +2,10 @@ package internal_test import ( "errors" + "fmt" "reflect" "runtime" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -789,12 +791,100 @@ var _ = Describe("Asynchronous Assertions", func() { ig.G.Eventually(func(ctx context.Context) string { return ctx.Value("key").(string) }).Should(Equal("value")) - Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually requested a context.Context, but no context has been provided to func(context.Context) string. Please pass one in using Eventually().WithContext().")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually requested a context.Context, but no context has been provided. Please pass one in using Eventually().WithContext().")) Ω(ig.FailureSkip).Should(Equal([]int{2})) }) }) }) + Context("when passed a function that takes additional arguments", func() { + Context("with just arguments", func() { + It("forwards those arguments along", func() { + Eventually(func(a int, b string) string { + return fmt.Sprintf("%d - %s", a, b) + }).WithArguments(10, "four").Should(Equal("10 - four")) + + Eventually(func(a int, b string, c ...int) string { + return fmt.Sprintf("%d - %s (%d%d%d)", a, b, c[0], c[1], c[2]) + }).WithArguments(10, "four", 5, 1, 0).Should(Equal("10 - four (510)")) + }) + }) + + Context("with a Gomega arugment as well", func() { + It("can also forward arguments alongside a Gomega", func() { + Eventually(func(g Gomega, a int, b int) { + g.Expect(a).To(Equal(b)) + }).WithArguments(10, 3).ShouldNot(Succeed()) + Eventually(func(g Gomega, a int, b int) { + g.Expect(a).To(Equal(b)) + }).WithArguments(3, 3).Should(Succeed()) + }) + }) + + Context("with a context arugment as well", func() { + It("can also forward arguments alongside a context", func() { + ctx := context.WithValue(context.Background(), "key", "value") + Eventually(func(ctx context.Context, animal string) string { + return ctx.Value("key").(string) + " " + animal + }).WithArguments("pony").WithContext(ctx).Should(Equal("value pony")) + }) + }) + + Context("with Gomega and context arugments", func() { + It("forwards arguments alongside both", func() { + ctx := context.WithValue(context.Background(), "key", "I have") + f := func(g Gomega, ctx context.Context, count int, zoo ...string) { + sentence := fmt.Sprintf("%s %d animals: %s", ctx.Value("key"), count, strings.Join(zoo, ", ")) + g.Expect(sentence).To(Equal("I have 3 animals: dog, cat, pony")) + } + + Eventually(f).WithArguments(3, "dog", "cat", "pony").WithContext(ctx).Should(Succeed()) + Eventually(f).WithArguments(2, "dog", "cat").WithContext(ctx).Should(MatchError(ContainSubstring("Expected\n : I have 2 animals: dog, cat\nto equal\n : I have 3 animals: dog, cat, pony"))) + }) + }) + + Context("with a context that is in the argument list", func() { + It("does not forward the configured context", func() { + ctxA := context.WithValue(context.Background(), "key", "A") + ctxB := context.WithValue(context.Background(), "key", "B") + + Eventually(func(ctx context.Context, a string) string { + return ctx.Value("key").(string) + " " + a + }).WithContext(ctxA).WithArguments(ctxB, "C").Should(Equal("B C")) + }) + }) + + Context("and an incorrect number of arguments is provided", func() { + It("errors", func() { + ig.G.Eventually(func(a int) string { + return "" + }).Should(Equal("foo")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually has signature func(int) string takes 1 arguments but 0 have been provided. Please use Eventually().WithArguments() to pass the corect set of arguments.")) + + ig.G.Eventually(func(a int, b int) string { + return "" + }).WithArguments(1).Should(Equal("foo")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually has signature func(int, int) string takes 2 arguments but 1 has been provided. Please use Eventually().WithArguments() to pass the corect set of arguments.")) + + ig.G.Eventually(func(a int, b int) string { + return "" + }).WithArguments(1, 2, 3).Should(Equal("foo")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually has signature func(int, int) string takes 2 arguments but 3 have been provided. Please use Eventually().WithArguments() to pass the corect set of arguments.")) + + ig.G.Eventually(func(g Gomega, a int, b int) string { + return "" + }).WithArguments(1, 2, 3).Should(Equal("foo")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually has signature func(types.Gomega, int, int) string takes 3 arguments but 4 have been provided. Please use Eventually().WithArguments() to pass the corect set of arguments.")) + + ig.G.Eventually(func(a int, b int, c ...int) string { + return "" + }).WithArguments(1).Should(Equal("foo")) + Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually has signature func(int, int, ...int) string takes 3 arguments but 1 has been provided. Please use Eventually().WithArguments() to pass the corect set of arguments.")) + + }) + }) + }) + Describe("when passed an invalid function", func() { It("errors with a failure", func() { ig.G.Eventually(func() {}).Should(Equal("foo")) @@ -805,11 +895,6 @@ var _ = Describe("Asynchronous Assertions", func() { Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Consistently had an invalid signature of func(context.Context)")) Ω(ig.FailureSkip).Should(Equal([]int{2})) - ig = NewInstrumentedGomega() - ig.G.Eventually(func(g Gomega, foo string) {}).Should(Equal("foo")) - Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually had an invalid signature of func(types.Gomega, string)")) - Ω(ig.FailureSkip).Should(Equal([]int{2})) - ig.G.Eventually(func(ctx context.Context, g Gomega) {}).Should(Equal("foo")) Ω(ig.FailureMessage).Should(ContainSubstring("The function passed to Eventually had an invalid signature of func(context.Context, types.Gomega)")) Ω(ig.FailureSkip).Should(Equal([]int{2})) diff --git a/types/types.go b/types/types.go index 8f2998a5e..b479e2e85 100644 --- a/types/types.go +++ b/types/types.go @@ -74,6 +74,7 @@ type AsyncAssertion interface { Within(timeout time.Duration) AsyncAssertion ProbeEvery(interval time.Duration) AsyncAssertion WithContext(ctx context.Context) AsyncAssertion + WithArguments(argsToForward ...interface{}) AsyncAssertion } // Assertions are returned by Ω and Expect and enable assertions against Gomega matchers