Skip to content

Commit 3a26311

Browse files
committedNov 5, 2021
Add HaveField matcher
1 parent 2f96943 commit 3a26311

File tree

3 files changed

+251
-0
lines changed

3 files changed

+251
-0
lines changed
 

‎matchers.go

+28
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,34 @@ func HaveKeyWithValue(key interface{}, value interface{}) types.GomegaMatcher {
342342
}
343343
}
344344

345+
//HaveField succeeds if actual is a struct and the value at the passed in field
346+
//matches the passed in matcher. By default HaveField used Equal() to perform the match,
347+
//however a matcher can be passed in in stead.
348+
//
349+
//The field must be a string that resolves to the name of a field in the struct. Structs can be traversed
350+
//using the '.' delimiter. If the field ends with '()' a method named field is assumed to exist on the struct and is invoked.
351+
//Such methods must take no arguments and return a single value:
352+
//
353+
// type Book struct {
354+
// Title string
355+
// Author Person
356+
// }
357+
// type Person struct {
358+
// FirstName string
359+
// LastName string
360+
// DOB time.Time
361+
// }
362+
// Expect(book).To(HaveField("Title", "Les Miserables"))
363+
// Expect(book).To(HaveField("Title", ContainSubstring("Les"))
364+
// Expect(book).To(HaveField("Person.FirstName", Equal("Victor"))
365+
// Expect(book).To(HaveField("Person.DOB.Year()", BeNumerically("<", 1900))
366+
func HaveField(field string, expected interface{}) types.GomegaMatcher {
367+
return &matchers.HaveFieldMatcher{
368+
Field: field,
369+
Expected: expected,
370+
}
371+
}
372+
345373
//BeNumerically performs numerical assertions in a type-agnostic way.
346374
//Actual and expected should be numbers, though the specific type of
347375
//number is irrelevant (float32, float64, uint8, etc...).

‎matchers/have_field.go

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package matchers
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
8+
"github.com/onsi/gomega/format"
9+
)
10+
11+
func extractField(actual interface{}, field string) (interface{}, error) {
12+
fields := strings.SplitN(field, ".", 2)
13+
actualValue := reflect.ValueOf(actual)
14+
15+
if actualValue.Kind() != reflect.Struct {
16+
return nil, fmt.Errorf("HaveField encountered:\n%s\nWhich is not a struct.", format.Object(actual, 1))
17+
}
18+
19+
var extractedValue reflect.Value
20+
21+
if strings.HasSuffix(fields[0], "()") {
22+
extractedValue = actualValue.MethodByName(strings.TrimSuffix(fields[0], "()"))
23+
if extractedValue == (reflect.Value{}) {
24+
return nil, fmt.Errorf("HaveField could not find method named '%s' in struct of type %T.", fields[0], actual)
25+
}
26+
t := extractedValue.Type()
27+
if t.NumIn() != 0 || t.NumOut() != 1 {
28+
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)
29+
}
30+
extractedValue = extractedValue.Call([]reflect.Value{})[0]
31+
} else {
32+
extractedValue = actualValue.FieldByName(fields[0])
33+
if extractedValue == (reflect.Value{}) {
34+
return nil, fmt.Errorf("HaveField could not find field named '%s' in struct:\n%s", fields[0], format.Object(actual, 1))
35+
}
36+
}
37+
38+
if len(fields) == 1 {
39+
return extractedValue.Interface(), nil
40+
} else {
41+
return extractField(extractedValue.Interface(), fields[1])
42+
}
43+
}
44+
45+
type HaveFieldMatcher struct {
46+
Field string
47+
Expected interface{}
48+
49+
extractedField interface{}
50+
expectedMatcher omegaMatcher
51+
}
52+
53+
func (matcher *HaveFieldMatcher) Match(actual interface{}) (success bool, err error) {
54+
matcher.extractedField, err = extractField(actual, matcher.Field)
55+
if err != nil {
56+
return false, err
57+
}
58+
59+
var isMatcher bool
60+
matcher.expectedMatcher, isMatcher = matcher.Expected.(omegaMatcher)
61+
if !isMatcher {
62+
matcher.expectedMatcher = &EqualMatcher{Expected: matcher.Expected}
63+
}
64+
65+
return matcher.expectedMatcher.Match(matcher.extractedField)
66+
}
67+
68+
func (matcher *HaveFieldMatcher) FailureMessage(actual interface{}) (message string) {
69+
message = fmt.Sprintf("Value for field '%s' failed to satisfy matcher.\n", matcher.Field)
70+
message += matcher.expectedMatcher.FailureMessage(matcher.extractedField)
71+
72+
return message
73+
}
74+
75+
func (matcher *HaveFieldMatcher) NegatedFailureMessage(actual interface{}) (message string) {
76+
message = fmt.Sprintf("Value for field '%s' satisfied matcher, but should not have.\n", matcher.Field)
77+
message += matcher.expectedMatcher.NegatedFailureMessage(matcher.extractedField)
78+
79+
return message
80+
}

‎matchers/have_field_test.go

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package matchers_test
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
. "github.com/onsi/ginkgo"
8+
. "github.com/onsi/ginkgo/extensions/table"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
type Book struct {
13+
Title string
14+
Author person
15+
Pages int
16+
}
17+
18+
func (book Book) AuthorName() string {
19+
return fmt.Sprintf("%s %s", book.Author.FirstName, book.Author.LastName)
20+
}
21+
22+
func (book Book) AbbreviatedAuthor() person {
23+
return person{
24+
FirstName: book.Author.FirstName[0:3],
25+
LastName: book.Author.LastName[0:3],
26+
DOB: book.Author.DOB,
27+
}
28+
}
29+
30+
func (book Book) NoReturn() {
31+
}
32+
33+
func (book Book) TooManyReturn() (string, error) {
34+
return "", nil
35+
}
36+
37+
func (book Book) HasArg(arg string) string {
38+
return arg
39+
}
40+
41+
type person struct {
42+
FirstName string
43+
LastName string
44+
DOB time.Time
45+
}
46+
47+
var _ = Describe("HaveField", func() {
48+
var book Book
49+
BeforeEach(func() {
50+
book = Book{
51+
Title: "Les Miserables",
52+
Author: person{
53+
FirstName: "Victor",
54+
LastName: "Hugo",
55+
DOB: time.Date(1802, 2, 26, 0, 0, 0, 0, time.UTC),
56+
},
57+
Pages: 2783,
58+
}
59+
})
60+
61+
DescribeTable("traversing the struct works",
62+
func(field string, expected interface{}) {
63+
Ω(book).Should(HaveField(field, expected))
64+
},
65+
Entry("Top-level field with default submatcher", "Title", "Les Miserables"),
66+
Entry("Top-level field with custom submatcher", "Title", ContainSubstring("Les Mis")),
67+
Entry("Nested field", "Author.FirstName", "Victor"),
68+
Entry("Top-level method", "AuthorName()", "Victor Hugo"),
69+
Entry("Nested method", "Author.DOB.Year()", BeNumerically("<", 1900)),
70+
Entry("Traversing past a method", "AbbreviatedAuthor().FirstName", Equal("Vic")),
71+
)
72+
73+
DescribeTable("negation works",
74+
func(field string, expected interface{}) {
75+
Ω(book).ShouldNot(HaveField(field, expected))
76+
},
77+
Entry("Top-level field with default submatcher", "Title", "Les Mis"),
78+
Entry("Top-level field with custom submatcher", "Title", ContainSubstring("Notre Dame")),
79+
Entry("Nested field", "Author.FirstName", "Hugo"),
80+
Entry("Top-level method", "AuthorName()", "Victor M. Hugo"),
81+
Entry("Nested method", "Author.DOB.Year()", BeNumerically(">", 1900)),
82+
)
83+
84+
Describe("when field lookup fails", func() {
85+
It("errors appropriately", func() {
86+
success, err := HaveField("BookName", "Les Miserables").Match(book)
87+
Ω(success).Should(BeFalse())
88+
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "BookName"))
89+
90+
success, err = HaveField("BookName", "Les Miserables").Match(book)
91+
Ω(success).Should(BeFalse())
92+
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "BookName"))
93+
94+
success, err = HaveField("AuthorName", "Victor Hugo").Match(book)
95+
Ω(success).Should(BeFalse())
96+
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "AuthorName"))
97+
98+
success, err = HaveField("Title()", "Les Miserables").Match(book)
99+
Ω(success).Should(BeFalse())
100+
Ω(err.Error()).Should(ContainSubstring("HaveField could not find method named '%s' in struct of type matchers_test.Book.", "Title()"))
101+
102+
success, err = HaveField("NoReturn()", "Les Miserables").Match(book)
103+
Ω(success).Should(BeFalse())
104+
Ω(err.Error()).Should(ContainSubstring("HaveField found an invalid method named 'NoReturn()' in struct of type matchers_test.Book.\nMethods must take no arguments and return exactly one value."))
105+
106+
success, err = HaveField("TooManyReturn()", "Les Miserables").Match(book)
107+
Ω(success).Should(BeFalse())
108+
Ω(err.Error()).Should(ContainSubstring("HaveField found an invalid method named 'TooManyReturn()' in struct of type matchers_test.Book.\nMethods must take no arguments and return exactly one value."))
109+
110+
success, err = HaveField("HasArg()", "Les Miserables").Match(book)
111+
Ω(success).Should(BeFalse())
112+
Ω(err.Error()).Should(ContainSubstring("HaveField found an invalid method named 'HasArg()' in struct of type matchers_test.Book.\nMethods must take no arguments and return exactly one value."))
113+
114+
success, err = HaveField("Pages.Count", 2783).Match(book)
115+
Ω(success).Should(BeFalse())
116+
Ω(err.Error()).Should(Equal("HaveField encountered:\n <int>: 2783\nWhich is not a struct."))
117+
118+
success, err = HaveField("Author.Abbreviation", "Vic").Match(book)
119+
Ω(success).Should(BeFalse())
120+
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "Abbreviation"))
121+
})
122+
})
123+
124+
Describe("Failure Messages", func() {
125+
It("renders the underlying matcher failure", func() {
126+
matcher := HaveField("Title", "Les Mis")
127+
success, err := matcher.Match(book)
128+
Ω(success).Should(BeFalse())
129+
Ω(err).ShouldNot(HaveOccurred())
130+
131+
msg := matcher.FailureMessage(book)
132+
Ω(msg).Should(Equal("Value for field 'Title' failed to satisfy matcher.\nExpected\n <string>: Les Miserables\nto equal\n <string>: Les Mis"))
133+
134+
matcher = HaveField("Title", "Les Miserables")
135+
success, err = matcher.Match(book)
136+
Ω(success).Should(BeTrue())
137+
Ω(err).ShouldNot(HaveOccurred())
138+
139+
msg = matcher.NegatedFailureMessage(book)
140+
Ω(msg).Should(Equal("Value for field 'Title' satisfied matcher, but should not have.\nExpected\n <string>: Les Miserables\nnot to equal\n <string>: Les Miserables"))
141+
})
142+
})
143+
})

0 commit comments

Comments
 (0)
Please sign in to comment.