Skip to content

Commit fd130e1

Browse files
authored
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
1 parent eb4b4c2 commit fd130e1

5 files changed

+168
-10
lines changed

docs/index.md

+18-2
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,7 @@ succeeds if the capacity of `ACTUAL` is `INT`. `ACTUAL` must be of type `array`,
889889
or
890890

891891
```go
892-
Ω(ACTUAL).Should(ContainElement(ELEMENT, <Pointer>))
892+
Ω(ACTUAL).Should(ContainElement(ELEMENT, <POINTER>))
893893
```
894894

895895

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

904-
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):
904+
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):
905905

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

10861086
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.
10871087

1088+
#### HaveExistingField(field interface{})
1089+
1090+
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).
1091+
1092+
```go
1093+
Ω(ACTUAL).Should(HaveExistingField(FIELD))
1094+
```
1095+
1096+
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.
1097+
1098+
To assert a particular field value, but only if such a field exists in an `ACTUAL` struct, use the composing [`And`](#andmatchers-gomegamatcher) matcher:
1099+
1100+
```go
1101+
Ω(ACTUAL).Should(And(HaveExistingField(FIELD), HaveField(FIELD, VALUE)))
1102+
```
1103+
10881104
### Working with Numbers and Times
10891105

10901106
#### BeNumerically(comparator string, compareTo ...interface{})

matchers.go

+13
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,19 @@ func HaveField(field string, expected interface{}) types.GomegaMatcher {
404404
}
405405
}
406406

407+
// HaveExistingField succeeds if actual is a struct and the specified field
408+
// exists.
409+
//
410+
// HaveExistingField can be combined with HaveField in order to cover use cases
411+
// with optional fields. HaveField alone would trigger an error in such situations.
412+
//
413+
// Expect(MrHarmless).NotTo(And(HaveExistingField("Title"), HaveField("Title", "Supervillain")))
414+
func HaveExistingField(field string) types.GomegaMatcher {
415+
return &matchers.HaveExistingFieldMatcher{
416+
Field: field,
417+
}
418+
}
419+
407420
// HaveValue applies the given matcher to the value of actual, optionally and
408421
// repeatedly dereferencing pointers or taking the concrete value of interfaces.
409422
// Thus, the matcher will always be applied to non-pointer and non-interface
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package matchers
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/onsi/gomega/format"
8+
)
9+
10+
type HaveExistingFieldMatcher struct {
11+
Field string
12+
}
13+
14+
func (matcher *HaveExistingFieldMatcher) Match(actual interface{}) (success bool, err error) {
15+
// we don't care about the field's actual value, just about any error in
16+
// trying to find the field (or method).
17+
_, err = extractField(actual, matcher.Field, "HaveExistingField")
18+
if err == nil {
19+
return true, nil
20+
}
21+
var mferr missingFieldError
22+
if errors.As(err, &mferr) {
23+
// missing field errors aren't errors in this context, but instead
24+
// unsuccessful matches.
25+
return false, nil
26+
}
27+
return false, err
28+
}
29+
30+
func (matcher *HaveExistingFieldMatcher) FailureMessage(actual interface{}) (message string) {
31+
return fmt.Sprintf("Expected\n%s\nto have field '%s'", format.Object(actual, 1), matcher.Field)
32+
}
33+
34+
func (matcher *HaveExistingFieldMatcher) NegatedFailureMessage(actual interface{}) (message string) {
35+
return fmt.Sprintf("Expected\n%s\nnot to have field '%s'", format.Object(actual, 1), matcher.Field)
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package matchers_test
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
var _ = Describe("HaveExistingField", func() {
11+
12+
var book Book
13+
BeforeEach(func() {
14+
book = Book{
15+
Title: "Les Miserables",
16+
Author: person{
17+
FirstName: "Victor",
18+
LastName: "Hugo",
19+
DOB: time.Date(1802, 2, 26, 0, 0, 0, 0, time.UTC),
20+
},
21+
Pages: 2783,
22+
Sequel: &Book{
23+
Title: "Les Miserables 2",
24+
},
25+
}
26+
})
27+
28+
DescribeTable("traversing the struct works",
29+
func(field string) {
30+
Ω(book).Should(HaveExistingField(field))
31+
},
32+
Entry("Top-level field", "Title"),
33+
Entry("Nested field", "Author.FirstName"),
34+
Entry("Top-level method", "AuthorName()"),
35+
Entry("Nested method", "Author.DOB.Year()"),
36+
Entry("Traversing past a method", "AbbreviatedAuthor().FirstName"),
37+
Entry("Traversing a pointer", "Sequel.Title"),
38+
)
39+
40+
DescribeTable("negation works",
41+
func(field string) {
42+
Ω(book).ShouldNot(HaveExistingField(field))
43+
},
44+
Entry("Top-level field", "Class"),
45+
Entry("Nested field", "Author.Class"),
46+
Entry("Top-level method", "ClassName()"),
47+
Entry("Nested method", "Author.DOB.BOT()"),
48+
Entry("Traversing past a method", "AbbreviatedAuthor().LastButOneName"),
49+
Entry("Traversing a pointer", "Sequel.Titles"),
50+
)
51+
52+
It("errors appropriately", func() {
53+
success, err := HaveExistingField("Pages.Count").Match(book)
54+
Ω(success).Should(BeFalse())
55+
Ω(err.Error()).Should(Equal("HaveExistingField encountered:\n <int>: 2783\nWhich is not a struct."))
56+
57+
success, err = HaveExistingField("Prequel.Title").Match(book)
58+
Ω(success).Should(BeFalse())
59+
Ω(err.Error()).Should(ContainSubstring("HaveExistingField encountered nil while dereferencing a pointer of type *matchers_test.Book."))
60+
61+
success, err = HaveExistingField("HasArg()").Match(book)
62+
Ω(success).Should(BeFalse())
63+
Ω(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."))
64+
})
65+
66+
It("renders failure messages", func() {
67+
matcher := HaveExistingField("Turtle")
68+
success, err := matcher.Match(book)
69+
Ω(success).Should(BeFalse())
70+
Ω(err).ShouldNot(HaveOccurred())
71+
72+
msg := matcher.FailureMessage(book)
73+
Ω(msg).Should(MatchRegexp(`(?s)Expected\n\s+<matchers_test\.Book>: .*\nto have field 'Turtle'`))
74+
75+
matcher = HaveExistingField("Title")
76+
success, err = matcher.Match(book)
77+
Ω(success).Should(BeTrue())
78+
Ω(err).ShouldNot(HaveOccurred())
79+
80+
msg = matcher.NegatedFailureMessage(book)
81+
Ω(msg).Should(MatchRegexp(`(?s)Expected\n\s+<matchers_test\.Book>: .*\nnot to have field 'Title'`))
82+
})
83+
84+
})

matchers/have_field.go

+17-8
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,28 @@ import (
88
"github.com/onsi/gomega/format"
99
)
1010

11-
func extractField(actual interface{}, field string) (interface{}, error) {
11+
// missingFieldError represents a missing field extraction error that
12+
// HaveExistingFieldMatcher can ignore, as opposed to other, sever field
13+
// extraction errors, such as nil pointers, et cetera.
14+
type missingFieldError string
15+
16+
func (e missingFieldError) Error() string {
17+
return string(e)
18+
}
19+
20+
func extractField(actual interface{}, field string, matchername string) (interface{}, error) {
1221
fields := strings.SplitN(field, ".", 2)
1322
actualValue := reflect.ValueOf(actual)
1423

1524
if actualValue.Kind() == reflect.Ptr {
1625
actualValue = actualValue.Elem()
1726
}
1827
if actualValue == (reflect.Value{}) {
19-
return nil, fmt.Errorf("HaveField encountered nil while dereferencing a pointer of type %T.", actual)
28+
return nil, fmt.Errorf("%s encountered nil while dereferencing a pointer of type %T.", matchername, actual)
2029
}
2130

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

2635
var extractedValue reflect.Value
@@ -31,24 +40,24 @@ func extractField(actual interface{}, field string) (interface{}, error) {
3140
extractedValue = actualValue.Addr().MethodByName(strings.TrimSuffix(fields[0], "()"))
3241
}
3342
if extractedValue == (reflect.Value{}) {
34-
return nil, fmt.Errorf("HaveField could not find method named '%s' in struct of type %T.", fields[0], actual)
43+
return nil, missingFieldError(fmt.Sprintf("%s could not find method named '%s' in struct of type %T.", matchername, fields[0], actual))
3544
}
3645
t := extractedValue.Type()
3746
if t.NumIn() != 0 || t.NumOut() != 1 {
38-
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)
47+
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)
3948
}
4049
extractedValue = extractedValue.Call([]reflect.Value{})[0]
4150
} else {
4251
extractedValue = actualValue.FieldByName(fields[0])
4352
if extractedValue == (reflect.Value{}) {
44-
return nil, fmt.Errorf("HaveField could not find field named '%s' in struct:\n%s", fields[0], format.Object(actual, 1))
53+
return nil, missingFieldError(fmt.Sprintf("%s could not find field named '%s' in struct:\n%s", matchername, fields[0], format.Object(actual, 1)))
4554
}
4655
}
4756

4857
if len(fields) == 1 {
4958
return extractedValue.Interface(), nil
5059
} else {
51-
return extractField(extractedValue.Interface(), fields[1])
60+
return extractField(extractedValue.Interface(), fields[1], matchername)
5261
}
5362
}
5463

@@ -61,7 +70,7 @@ type HaveFieldMatcher struct {
6170
}
6271

6372
func (matcher *HaveFieldMatcher) Match(actual interface{}) (success bool, err error) {
64-
matcher.extractedField, err = extractField(actual, matcher.Field)
73+
matcher.extractedField, err = extractField(actual, matcher.Field, "HaveField")
6574
if err != nil {
6675
return false, err
6776
}

0 commit comments

Comments
 (0)