Skip to content

Commit

Permalink
HaveExistingField matcher (#553)
Browse files Browse the repository at this point in the history
- implements new HaveExistingField matcher, fixing #548.
- modifies existing extractField helper from HaveField for reuse with HaveExistingField
- adds new unit tests for HaveExistingField matcher
- updates documentation
  • Loading branch information
thediveo committed May 20, 2022
1 parent eb4b4c2 commit fd130e1
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 10 deletions.
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

0 comments on commit fd130e1

Please sign in to comment.