From fd130e126467fbdee60e707e887df557f3e642cf Mon Sep 17 00:00:00 2001 From: TheDiveO <6920158+thediveo@users.noreply.github.com> Date: Fri, 20 May 2022 18:51:19 +0200 Subject: [PATCH] HaveExistingField matcher (#553) - 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 --- docs/index.md | 20 ++++- matchers.go | 13 +++ matchers/have_existing_field_matcher.go | 36 +++++++++ matchers/have_existing_field_matcher_test.go | 84 ++++++++++++++++++++ matchers/have_field.go | 25 ++++-- 5 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 matchers/have_existing_field_matcher.go create mode 100644 matchers/have_existing_field_matcher_test.go diff --git a/docs/index.md b/docs/index.md index f23281bc5..28828c617 100644 --- a/docs/index.md +++ b/docs/index.md @@ -889,7 +889,7 @@ succeeds if the capacity of `ACTUAL` is `INT`. `ACTUAL` must be of type `array`, or ```go -Ω(ACTUAL).Should(ContainElement(ELEMENT, )) +Ω(ACTUAL).Should(ContainElement(ELEMENT, )) ``` @@ -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 @@ -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, )`](#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{}) diff --git a/matchers.go b/matchers.go index 82e5ebb98..d6a099060 100644 --- a/matchers.go +++ b/matchers.go @@ -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 diff --git a/matchers/have_existing_field_matcher.go b/matchers/have_existing_field_matcher.go new file mode 100644 index 000000000..b57018745 --- /dev/null +++ b/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) +} diff --git a/matchers/have_existing_field_matcher_test.go b/matchers/have_existing_field_matcher_test.go new file mode 100644 index 000000000..4d2206a5a --- /dev/null +++ b/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 : 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+: .*\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+: .*\nnot to have field 'Title'`)) + }) + +}) diff --git a/matchers/have_field.go b/matchers/have_field.go index 0ff4670d9..6989f78c4 100644 --- a/matchers/have_field.go +++ b/matchers/have_field.go @@ -8,7 +8,16 @@ 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) @@ -16,11 +25,11 @@ func extractField(actual interface{}, field string) (interface{}, error) { 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 @@ -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) } } @@ -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 }