Skip to content

Commit

Permalink
Eventually and Consistently support functions that make assertions
Browse files Browse the repository at this point in the history
- Eventually and Consistently now allow their passed-in functions to make assertions.
These assertions must pass or the function is considered to have failed and is retried.
- Eventually and Consistently can now take functions with no return values.  These implicitly return nil
if they contain no failed assertion.  Otherwise they return an error wrapping the first assertion failure.  This allows
these functions to be used with the Succeed() matcher.
- Introduce InterceptGomegaFailure - an analogue to InterceptGomegaFailures - that captures the first assertion failure
and halts execution in its passed-in callback.
  • Loading branch information
onsi committed Jun 10, 2021
1 parent febd7a2 commit 2f04e6e
Show file tree
Hide file tree
Showing 5 changed files with 296 additions and 21 deletions.
86 changes: 73 additions & 13 deletions gomega_dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Gomega is MIT-Licensed
package gomega

import (
"errors"
"fmt"
"reflect"
"time"
Expand Down Expand Up @@ -91,10 +92,8 @@ func RegisterTestingT(t types.GomegaTestingT) {

// InterceptGomegaFailures runs a given callback and returns an array of
// failure messages generated by any Gomega assertions within the callback.
//
// This is accomplished by temporarily replacing the *global* fail handler
// with a fail handler that simply annotates failures. The original fail handler
// is reset when InterceptGomegaFailures returns.
// Exeuction continues after the first failure allowing users to collect all failures
// in the callback.
//
// This is most useful when testing custom matchers, but can also be used to check
// on a value using a Gomega assertion without causing a test failure.
Expand All @@ -104,11 +103,39 @@ func InterceptGomegaFailures(f func()) []string {
RegisterFailHandler(func(message string, callerSkip ...int) {
failures = append(failures, message)
})
defer func() {
RegisterFailHandler(originalHandler)
}()
f()
RegisterFailHandler(originalHandler)
return failures
}

// InterceptGomegaFailure runs a given callback and returns the first
// failure message generated by any Gomega assertions within the callback, wrapped in an error.
//
// The callback ceases execution as soon as the first failed assertion occurs, however Gomega
// does not register a failure with the FailHandler registered via RegisterFailHandler - it is up
// to the user to decide what to do with the returned error
func InterceptGomegaFailure(f func()) (err error) {
originalHandler := globalFailWrapper.Fail
RegisterFailHandler(func(message string, callerSkip ...int) {
err = errors.New(message)
panic("stop execution")
})

defer func() {
RegisterFailHandler(originalHandler)
if e := recover(); e != nil {
if err == nil {
panic(e)
}
}
}()

f()
return err
}

// Ω wraps an actual value allowing assertions to be made on it:
// Ω("foo").Should(Equal("foo"))
//
Expand Down Expand Up @@ -177,7 +204,7 @@ func ExpectWithOffset(offset int, actual interface{}, extra ...interface{}) Asse
// Both intervals can either be specified as time.Duration, parsable duration strings or as floats/integers. In the
// last case they are interpreted as seconds.
//
// If Eventually is passed an actual that is a function taking no arguments and returning at least one value,
// If Eventually is passed an actual that is a function taking no arguments,
// then Eventually will call the function periodically and try the matcher against the function's first return value.
//
// Example:
Expand All @@ -202,6 +229,34 @@ func ExpectWithOffset(offset int, actual interface{}, extra ...interface{}) Asse
//
// Will pass only if the the returned error is nil and the returned string passes the matcher.
//
// Eventually allows you to make assertions in the pased-in function. The function is assumed to have failed and will be retried if any assertion in the function fails.
// For example:
//
// Eventually(func() Widget {
// resp, err := http.Get(url)
// Expect(err).NotTo(HaveOccurred())
// defer resp.Body.Close()
// Expect(resp.SatusCode).To(Equal(http.StatusOK))
// var widget Widget
// Expect(json.NewDecoder(resp.Body).Decode(&widget)).To(Succeed())
// return widget
// }).Should(Equal(expectedWidget))
//
// will keep trying the passed-in function until all its assertsions pass (i.e. the http request succeeds) _and_ the returned object satisfies the passed-in matcher.
//
// Functions passed to Eventually typically have a return value. However you are allowed to pass in a function with no return value. Eventually assumes such a function
// is making assertions and will turn it into a function that returns an error if any assertion fails, or nil if no assertion fails. This allows you to use the Succeed() matcher
// to express that a complex operation should eventually succeed. For example:
//
// Eventually(func() {
// model, err := db.Find("foo")
// Expect(err).NotTo(HaveOccurred())
// Expect(model.Reticulated()).To(BeTrue())
// Expect(model.Save()).To(Succeed())
// }).Should(Succeed())
//
// will rerun the function until all its assertions pass.
//
// Eventually's default timeout is 1 second, and its default polling interval is 10ms
func Eventually(actual interface{}, intervals ...interface{}) AsyncAssertion {
return EventuallyWithOffset(0, actual, intervals...)
Expand Down Expand Up @@ -235,13 +290,18 @@ func EventuallyWithOffset(offset int, actual interface{}, intervals ...interface
// Both intervals can either be specified as time.Duration, parsable duration strings or as floats/integers. In the
// last case they are interpreted as seconds.
//
// If Consistently is passed an actual that is a function taking no arguments and returning at least one value,
// then Consistently will call the function periodically and try the matcher against the function's first return value.
// If Consistently is passed an actual that is a function taking no arguments.
//
// If the function returns one value, then Consistently will call the function periodically and try the matcher against the function's first return value.
//
// If the function returns more than one value, then Consistently will pass the first value to the matcher and
// assert that all other values are nil/zero.
// This allows you to pass Consistently a function that returns a value and an error - a common pattern in Go.
//
// Like Eventually, Consistently allows you to make assertions in the function. If any assertion fails Consistently will fail. In addition,
// Consistently also allows you to pass in a function with no return value. In this case Consistently can be paired with the Succeed() matcher to assert
// that no assertions in the function fail.
//
// Consistently is useful in cases where you want to assert that something *does not happen* over a period of time.
// For example, you want to assert that a goroutine does *not* send data down a channel. In this case, you could:
//
Expand Down Expand Up @@ -350,7 +410,7 @@ type OmegaMatcher types.GomegaMatcher
//
// Use `NewWithT` to instantiate a `WithT`
type WithT struct {
t types.GomegaTestingT
failWrapper *types.GomegaFailWrapper
}

// GomegaWithT is deprecated in favor of gomega.WithT, which does not stutter.
Expand All @@ -367,7 +427,7 @@ type GomegaWithT = WithT
// }
func NewWithT(t types.GomegaTestingT) *WithT {
return &WithT{
t: t,
failWrapper: testingtsupport.BuildTestingTGomegaFailWrapper(t),
}
}

Expand All @@ -378,7 +438,7 @@ func NewGomegaWithT(t types.GomegaTestingT) *GomegaWithT {

// ExpectWithOffset is used to make assertions. See documentation for ExpectWithOffset.
func (g *WithT) ExpectWithOffset(offset int, actual interface{}, extra ...interface{}) Assertion {
return assertion.New(actual, testingtsupport.BuildTestingTGomegaFailWrapper(g.t), offset, extra...)
return assertion.New(actual, g.failWrapper, offset, extra...)
}

// EventuallyWithOffset is used to make asynchronous assertions. See documentation for EventuallyWithOffset.
Expand All @@ -391,7 +451,7 @@ func (g *WithT) EventuallyWithOffset(offset int, actual interface{}, intervals .
if len(intervals) > 1 {
pollingInterval = toDuration(intervals[1])
}
return asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, actual, testingtsupport.BuildTestingTGomegaFailWrapper(g.t), timeoutInterval, pollingInterval, offset)
return asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, actual, g.failWrapper, timeoutInterval, pollingInterval, offset)
}

// ConsistentlyWithOffset is used to make asynchronous assertions. See documentation for ConsistentlyWithOffset.
Expand All @@ -404,7 +464,7 @@ func (g *WithT) ConsistentlyWithOffset(offset int, actual interface{}, intervals
if len(intervals) > 1 {
pollingInterval = toDuration(intervals[1])
}
return asyncassertion.New(asyncassertion.AsyncAssertionTypeConsistently, actual, testingtsupport.BuildTestingTGomegaFailWrapper(g.t), timeoutInterval, pollingInterval, offset)
return asyncassertion.New(asyncassertion.AsyncAssertionTypeConsistently, actual, g.failWrapper, timeoutInterval, pollingInterval, offset)
}

// Expect is used to make assertions. See documentation for Expect.
Expand Down
49 changes: 43 additions & 6 deletions internal/asyncassertion/async_assertion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"reflect"
"runtime"
"time"

"github.com/onsi/gomega/internal/oraclematcher"
Expand All @@ -31,8 +32,8 @@ type AsyncAssertion struct {
func New(asyncType AsyncAssertionType, actualInput interface{}, failWrapper *types.GomegaFailWrapper, timeoutInterval time.Duration, pollingInterval time.Duration, offset int) *AsyncAssertion {
actualType := reflect.TypeOf(actualInput)
if actualType.Kind() == reflect.Func {
if actualType.NumIn() != 0 || actualType.NumOut() == 0 {
panic("Expected a function with no arguments and one or more return values.")
if actualType.NumIn() != 0 {
panic("Expected a function with no arguments and zero or more return values.")
}
}

Expand Down Expand Up @@ -70,13 +71,49 @@ func (assertion *AsyncAssertion) buildDescription(optionalDescription ...interfa

func (assertion *AsyncAssertion) actualInputIsAFunction() bool {
actualType := reflect.TypeOf(assertion.actualInput)
return actualType.Kind() == reflect.Func && actualType.NumIn() == 0 && actualType.NumOut() > 0
return actualType.Kind() == reflect.Func && actualType.NumIn() == 0
}

func (assertion *AsyncAssertion) pollActual() (interface{}, error) {
if assertion.actualInputIsAFunction() {
values := reflect.ValueOf(assertion.actualInput).Call([]reflect.Value{})
if !assertion.actualInputIsAFunction() {
return assertion.actualInput, nil
}
var capturedAssertionFailure string
var values []reflect.Value

numOut := reflect.TypeOf(assertion.actualInput).NumOut()

func() {
originalHandler := assertion.failWrapper.Fail
assertion.failWrapper.Fail = func(message string, callerSkip ...int) {
skip := 0
if len(callerSkip) > 0 {
skip = callerSkip[0]
}
_, file, line, _ := runtime.Caller(skip + 1)
capturedAssertionFailure = fmt.Sprintf("Assertion in callback at %s:%d failed:\n%s", file, line, message)
panic("stop execution")
}

defer func() {
assertion.failWrapper.Fail = originalHandler
if e := recover(); e != nil && capturedAssertionFailure == "" {
panic(e)
}
}()

values = reflect.ValueOf(assertion.actualInput).Call([]reflect.Value{})
}()

if capturedAssertionFailure != "" {
if numOut == 0 {
return errors.New(capturedAssertionFailure), nil
} else {
return nil, errors.New(capturedAssertionFailure)
}
}

if numOut > 0 {
extras := []interface{}{}
for _, value := range values[1:] {
extras = append(extras, value.Interface())
Expand All @@ -91,7 +128,7 @@ func (assertion *AsyncAssertion) pollActual() (interface{}, error) {
return values[0].Interface(), nil
}

return assertion.actualInput, nil
return nil, nil
}

func (assertion *AsyncAssertion) matcherMayChange(matcher types.GomegaMatcher, value interface{}) bool {
Expand Down
104 changes: 102 additions & 2 deletions internal/asyncassertion/async_assertion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package asyncassertion_test

import (
"errors"
"runtime"
"time"

"github.com/onsi/gomega/internal/testingtsupport"
Expand Down Expand Up @@ -182,6 +183,52 @@ var _ = Describe("Async Assertion", func() {
})
})

Context("when the polled function makes assertions", func() {
It("fails if those assertions never succeed", func() {
var file string
var line int
err := InterceptGomegaFailure(func() {
i := 0
Eventually(func() int {
_, file, line, _ = runtime.Caller(0)
Expect(i).To(BeNumerically(">", 5))
return 2
}, 200*time.Millisecond, 20*time.Millisecond).Should(Equal(2))
})
Ω(err.Error()).Should(ContainSubstring("Timed out after"))
Ω(err.Error()).Should(ContainSubstring("Assertion in callback at %s:%d failed:", file, line+1))
Ω(err.Error()).Should(ContainSubstring("to be >"))
})

It("eventually succeeds if the assertions succeed", func() {
err := InterceptGomegaFailure(func() {
i := 0
Eventually(func() int {
i++
Expect(i).To(BeNumerically(">", 5))
return 2
}, 200*time.Millisecond, 20*time.Millisecond).Should(Equal(2))
})
Ω(err).ShouldNot(HaveOccurred())
})

It("succeeds if the assertions succeed even if the function doesn't return anything", func() {
i := 0
Eventually(func() {
i++
Expect(i).To(BeNumerically(">", 5))
}, 200*time.Millisecond, 20*time.Millisecond).Should(Succeed())
})

It("succeeds if the function returns nothing, the assertions eventually fail and the Eventually is assertion that it ShouldNot(Succeed()) ", func() {
i := 0
Eventually(func() {
i++
Expect(i).To(BeNumerically("<", 5))
}, 200*time.Millisecond, 20*time.Millisecond).ShouldNot(Succeed())
})
})

Context("Making an assertion without a registered fail handler", func() {
It("should panic", func() {
defer func() {
Expand Down Expand Up @@ -316,6 +363,55 @@ var _ = Describe("Async Assertion", func() {
})
})

Context("when the polled function makes assertions", func() {
It("fails if those assertions ever fail", func() {
var file string
var line int

err := InterceptGomegaFailure(func() {
i := 0
Consistently(func() int {
_, file, line, _ = runtime.Caller(0)
Expect(i).To(BeNumerically("<", 5))
i++
return 2
}, 200*time.Millisecond, 20*time.Millisecond).Should(Equal(2))
})
Ω(err.Error()).Should(ContainSubstring("Failed after"))
Ω(err.Error()).Should(ContainSubstring("Assertion in callback at %s:%d failed:", file, line+1))
Ω(err.Error()).Should(ContainSubstring("to be <"))
})

It("succeeds if the assertion consistently succeeds", func() {
err := InterceptGomegaFailure(func() {
i := 0
Consistently(func() int {
i++
Expect(i).To(BeNumerically("<", 1000))
return 2
}, 200*time.Millisecond, 20*time.Millisecond).Should(Equal(2))
})
Ω(err).ShouldNot(HaveOccurred())
})

It("succeeds if the assertions succeed even if the function doesn't return anything", func() {
i := 0
Consistently(func() {
i++
Expect(i).To(BeNumerically("<", 1000))
}, 200*time.Millisecond, 20*time.Millisecond).Should(Succeed())
})

It("succeeds if the assertions fail even if the function doesn't return anything and Consistently is checking for ShouldNot(Succeed())", func() {
i := 0
Consistently(func() {
i++
Expect(i).To(BeNumerically(">", 1000))
}, 200*time.Millisecond, 20*time.Millisecond).ShouldNot(Succeed())
})

})

Context("Making an assertion without a registered fail handler", func() {
It("should panic", func() {
defer func() {
Expand All @@ -333,16 +429,20 @@ var _ = Describe("Async Assertion", func() {
})
})

When("passed a function with the wrong # or arguments & returns", func() {
When("passed a function with the wrong # or arguments", func() {
It("should panic", func() {
Expect(func() {
asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, func() {}, fakeFailWrapper, 0, 0, 1)
}).Should(Panic())
}).ShouldNot(Panic())

Expect(func() {
asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, func(a string) int { return 0 }, fakeFailWrapper, 0, 0, 1)
}).Should(Panic())

Expect(func() {
asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, func(a string) {}, fakeFailWrapper, 0, 0, 1)
}).Should(Panic())

Expect(func() {
asyncassertion.New(asyncassertion.AsyncAssertionTypeEventually, func() int { return 0 }, fakeFailWrapper, 0, 0, 1)
}).ShouldNot(Panic())
Expand Down
Loading

0 comments on commit 2f04e6e

Please sign in to comment.