Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HaveExistingField matcher #553

Merged
merged 1 commit into from May 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/index.md
Expand Up @@ -889,7 +889,7 @@ succeeds if the capacity of `ACTUAL` is `INT`. `ACTUAL` must be of type `array`,
or

```go
Ω(ACTUAL).Should(ContainElement(ELEMENT, <Pointer>))
Ω(ACTUAL).Should(ContainElement(ELEMENT, <POINTER>))
```


Expand All @@ -901,7 +901,7 @@ By default `ContainElement()` uses the `Equal()` matcher under the hood to asser
Ω([]string{"Foo", "FooBar"}).Should(ContainElement(ContainSubstring("Bar")))
```

In addition, there are occasions when you need to grab (all) matching contained elements, for instance, to make several assertions against the matching contained elements. To do this, you can ask the `ContainElement` matcher for the matching contained elements by passing it a pointer to a variable of the appropriate type. If multiple matching contained elements are expected, then a pointer to either a slice or a map should be passed (but not a pointer to an array), otherwise a pointer to a scalar (non-slice, non-map):
In addition, there are occasions when you need to grab (all) matching contained elements, for instance, to make several assertions against the matching contained elements. To do this, you can ask the `ContainElement()` matcher for the matching contained elements by passing it a pointer to a variable of the appropriate type. If multiple matching contained elements are expected, then a pointer to either a slice or a map should be passed (but not a pointer to an array), otherwise a pointer to a scalar (non-slice, non-map):

```go
var findings []string
Expand Down Expand Up @@ -1085,6 +1085,22 @@ and an instance book `var book = Book{...}` - you can use `HaveField` to make as

If you want to make lots of complex assertions against the fields of a struct take a look at the [`gstruct`package](#gstruct-testing-complex-data-types) package documented below.

#### HaveExistingField(field interface{})

While `HaveField()` considers a missing field to be an error (instead of non-success), combining it with `HaveExistingField()` allows `HaveField()` to be reused in test contexts other than assertions: for instance, as filters to [`ContainElement(ELEMENT, <POINTER>)`](#containelementelement-interface) or in detecting resource leaks (like leaked file descriptors).

```go
Ω(ACTUAL).Should(HaveExistingField(FIELD))
```

succeeds if `ACTUAL` is a struct with a field `FIELD`, regardless of this field's value. It is an error for `ACTUAL` to not be a `struct`. Like `HaveField()`, `HaveExistingField()` supports accessing nested structs using the `.` delimiter. Methods on the struct are invoked by adding a `()` suffix to the `FIELD` - these methods must take no arguments and return exactly one value.

To assert a particular field value, but only if such a field exists in an `ACTUAL` struct, use the composing [`And`](#andmatchers-gomegamatcher) matcher:

```go
Ω(ACTUAL).Should(And(HaveExistingField(FIELD), HaveField(FIELD, VALUE)))
```

### Working with Numbers and Times

#### BeNumerically(comparator string, compareTo ...interface{})
Expand Down
13 changes: 13 additions & 0 deletions matchers.go
Expand Up @@ -404,6 +404,19 @@ func HaveField(field string, expected interface{}) types.GomegaMatcher {
}
}

// HaveExistingField succeeds if actual is a struct and the specified field
// exists.
//
// HaveExistingField can be combined with HaveField in order to cover use cases
// with optional fields. HaveField alone would trigger an error in such situations.
//
// Expect(MrHarmless).NotTo(And(HaveExistingField("Title"), HaveField("Title", "Supervillain")))
func HaveExistingField(field string) types.GomegaMatcher {
return &matchers.HaveExistingFieldMatcher{
Field: field,
}
}

// HaveValue applies the given matcher to the value of actual, optionally and
// repeatedly dereferencing pointers or taking the concrete value of interfaces.
// Thus, the matcher will always be applied to non-pointer and non-interface
Expand Down
36 changes: 36 additions & 0 deletions matchers/have_existing_field_matcher.go
@@ -0,0 +1,36 @@
package matchers

import (
"errors"
"fmt"

"github.com/onsi/gomega/format"
)

type HaveExistingFieldMatcher struct {
Field string
}

func (matcher *HaveExistingFieldMatcher) Match(actual interface{}) (success bool, err error) {
// we don't care about the field's actual value, just about any error in
// trying to find the field (or method).
_, err = extractField(actual, matcher.Field, "HaveExistingField")
if err == nil {
return true, nil
}
var mferr missingFieldError
if errors.As(err, &mferr) {
// missing field errors aren't errors in this context, but instead
// unsuccessful matches.
return false, nil
}
return false, err
}

func (matcher *HaveExistingFieldMatcher) FailureMessage(actual interface{}) (message string) {
return fmt.Sprintf("Expected\n%s\nto have field '%s'", format.Object(actual, 1), matcher.Field)
}

func (matcher *HaveExistingFieldMatcher) NegatedFailureMessage(actual interface{}) (message string) {
return fmt.Sprintf("Expected\n%s\nnot to have field '%s'", format.Object(actual, 1), matcher.Field)
}
84 changes: 84 additions & 0 deletions matchers/have_existing_field_matcher_test.go
@@ -0,0 +1,84 @@
package matchers_test

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("HaveExistingField", func() {

var book Book
BeforeEach(func() {
book = Book{
Title: "Les Miserables",
Author: person{
FirstName: "Victor",
LastName: "Hugo",
DOB: time.Date(1802, 2, 26, 0, 0, 0, 0, time.UTC),
},
Pages: 2783,
Sequel: &Book{
Title: "Les Miserables 2",
},
}
})

DescribeTable("traversing the struct works",
func(field string) {
Ω(book).Should(HaveExistingField(field))
},
Entry("Top-level field", "Title"),
Entry("Nested field", "Author.FirstName"),
Entry("Top-level method", "AuthorName()"),
Entry("Nested method", "Author.DOB.Year()"),
Entry("Traversing past a method", "AbbreviatedAuthor().FirstName"),
Entry("Traversing a pointer", "Sequel.Title"),
)

DescribeTable("negation works",
func(field string) {
Ω(book).ShouldNot(HaveExistingField(field))
},
Entry("Top-level field", "Class"),
Entry("Nested field", "Author.Class"),
Entry("Top-level method", "ClassName()"),
Entry("Nested method", "Author.DOB.BOT()"),
Entry("Traversing past a method", "AbbreviatedAuthor().LastButOneName"),
Entry("Traversing a pointer", "Sequel.Titles"),
)

It("errors appropriately", func() {
success, err := HaveExistingField("Pages.Count").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(Equal("HaveExistingField encountered:\n <int>: 2783\nWhich is not a struct."))

success, err = HaveExistingField("Prequel.Title").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveExistingField encountered nil while dereferencing a pointer of type *matchers_test.Book."))

success, err = HaveExistingField("HasArg()").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveExistingField found an invalid method named 'HasArg()' in struct of type matchers_test.Book.\nMethods must take no arguments and return exactly one value."))
})

It("renders failure messages", func() {
matcher := HaveExistingField("Turtle")
success, err := matcher.Match(book)
Ω(success).Should(BeFalse())
Ω(err).ShouldNot(HaveOccurred())

msg := matcher.FailureMessage(book)
Ω(msg).Should(MatchRegexp(`(?s)Expected\n\s+<matchers_test\.Book>: .*\nto have field 'Turtle'`))

matcher = HaveExistingField("Title")
success, err = matcher.Match(book)
Ω(success).Should(BeTrue())
Ω(err).ShouldNot(HaveOccurred())

msg = matcher.NegatedFailureMessage(book)
Ω(msg).Should(MatchRegexp(`(?s)Expected\n\s+<matchers_test\.Book>: .*\nnot to have field 'Title'`))
})

})
25 changes: 17 additions & 8 deletions matchers/have_field.go
Expand Up @@ -8,19 +8,28 @@ import (
"github.com/onsi/gomega/format"
)

func extractField(actual interface{}, field string) (interface{}, error) {
// missingFieldError represents a missing field extraction error that
// HaveExistingFieldMatcher can ignore, as opposed to other, sever field
// extraction errors, such as nil pointers, et cetera.
type missingFieldError string

func (e missingFieldError) Error() string {
return string(e)
}

func extractField(actual interface{}, field string, matchername string) (interface{}, error) {
fields := strings.SplitN(field, ".", 2)
actualValue := reflect.ValueOf(actual)

if actualValue.Kind() == reflect.Ptr {
actualValue = actualValue.Elem()
}
if actualValue == (reflect.Value{}) {
return nil, fmt.Errorf("HaveField encountered nil while dereferencing a pointer of type %T.", actual)
return nil, fmt.Errorf("%s encountered nil while dereferencing a pointer of type %T.", matchername, actual)
}

if actualValue.Kind() != reflect.Struct {
return nil, fmt.Errorf("HaveField encountered:\n%s\nWhich is not a struct.", format.Object(actual, 1))
return nil, fmt.Errorf("%s encountered:\n%s\nWhich is not a struct.", matchername, format.Object(actual, 1))
}

var extractedValue reflect.Value
Expand All @@ -31,24 +40,24 @@ func extractField(actual interface{}, field string) (interface{}, error) {
extractedValue = actualValue.Addr().MethodByName(strings.TrimSuffix(fields[0], "()"))
}
if extractedValue == (reflect.Value{}) {
return nil, fmt.Errorf("HaveField could not find method named '%s' in struct of type %T.", fields[0], actual)
return nil, missingFieldError(fmt.Sprintf("%s could not find method named '%s' in struct of type %T.", matchername, fields[0], actual))
}
t := extractedValue.Type()
if t.NumIn() != 0 || t.NumOut() != 1 {
return nil, fmt.Errorf("HaveField found an invalid method named '%s' in struct of type %T.\nMethods must take no arguments and return exactly one value.", fields[0], actual)
return nil, fmt.Errorf("%s found an invalid method named '%s' in struct of type %T.\nMethods must take no arguments and return exactly one value.", matchername, fields[0], actual)
}
extractedValue = extractedValue.Call([]reflect.Value{})[0]
} else {
extractedValue = actualValue.FieldByName(fields[0])
if extractedValue == (reflect.Value{}) {
return nil, fmt.Errorf("HaveField could not find field named '%s' in struct:\n%s", fields[0], format.Object(actual, 1))
return nil, missingFieldError(fmt.Sprintf("%s could not find field named '%s' in struct:\n%s", matchername, fields[0], format.Object(actual, 1)))
}
}

if len(fields) == 1 {
return extractedValue.Interface(), nil
} else {
return extractField(extractedValue.Interface(), fields[1])
return extractField(extractedValue.Interface(), fields[1], matchername)
}
}

Expand All @@ -61,7 +70,7 @@ type HaveFieldMatcher struct {
}

func (matcher *HaveFieldMatcher) Match(actual interface{}) (success bool, err error) {
matcher.extractedField, err = extractField(actual, matcher.Field)
matcher.extractedField, err = extractField(actual, matcher.Field, "HaveField")
if err != nil {
return false, err
}
Expand Down