Skip to content

Commit

Permalink
Add HaveField matcher
Browse files Browse the repository at this point in the history
  • Loading branch information
onsi committed Nov 5, 2021
1 parent 2f96943 commit 3a26311
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 0 deletions.
28 changes: 28 additions & 0 deletions matchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,34 @@ func HaveKeyWithValue(key interface{}, value interface{}) types.GomegaMatcher {
}
}

//HaveField succeeds if actual is a struct and the value at the passed in field
//matches the passed in matcher. By default HaveField used Equal() to perform the match,
//however a matcher can be passed in in stead.
//
//The field must be a string that resolves to the name of a field in the struct. Structs can be traversed
//using the '.' delimiter. If the field ends with '()' a method named field is assumed to exist on the struct and is invoked.
//Such methods must take no arguments and return a single value:
//
// type Book struct {
// Title string
// Author Person
// }
// type Person struct {
// FirstName string
// LastName string
// DOB time.Time
// }
// Expect(book).To(HaveField("Title", "Les Miserables"))
// Expect(book).To(HaveField("Title", ContainSubstring("Les"))
// Expect(book).To(HaveField("Person.FirstName", Equal("Victor"))
// Expect(book).To(HaveField("Person.DOB.Year()", BeNumerically("<", 1900))
func HaveField(field string, expected interface{}) types.GomegaMatcher {
return &matchers.HaveFieldMatcher{
Field: field,
Expected: expected,
}
}

//BeNumerically performs numerical assertions in a type-agnostic way.
//Actual and expected should be numbers, though the specific type of
//number is irrelevant (float32, float64, uint8, etc...).
Expand Down
80 changes: 80 additions & 0 deletions matchers/have_field.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package matchers

import (
"fmt"
"reflect"
"strings"

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

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

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

var extractedValue reflect.Value

if strings.HasSuffix(fields[0], "()") {
extractedValue = actualValue.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)
}
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)
}
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))
}
}

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

type HaveFieldMatcher struct {
Field string
Expected interface{}

extractedField interface{}
expectedMatcher omegaMatcher
}

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

var isMatcher bool
matcher.expectedMatcher, isMatcher = matcher.Expected.(omegaMatcher)
if !isMatcher {
matcher.expectedMatcher = &EqualMatcher{Expected: matcher.Expected}
}

return matcher.expectedMatcher.Match(matcher.extractedField)
}

func (matcher *HaveFieldMatcher) FailureMessage(actual interface{}) (message string) {
message = fmt.Sprintf("Value for field '%s' failed to satisfy matcher.\n", matcher.Field)
message += matcher.expectedMatcher.FailureMessage(matcher.extractedField)

return message
}

func (matcher *HaveFieldMatcher) NegatedFailureMessage(actual interface{}) (message string) {
message = fmt.Sprintf("Value for field '%s' satisfied matcher, but should not have.\n", matcher.Field)
message += matcher.expectedMatcher.NegatedFailureMessage(matcher.extractedField)

return message
}
143 changes: 143 additions & 0 deletions matchers/have_field_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package matchers_test

import (
"fmt"
"time"

. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/extensions/table"
. "github.com/onsi/gomega"
)

type Book struct {
Title string
Author person
Pages int
}

func (book Book) AuthorName() string {
return fmt.Sprintf("%s %s", book.Author.FirstName, book.Author.LastName)
}

func (book Book) AbbreviatedAuthor() person {
return person{
FirstName: book.Author.FirstName[0:3],
LastName: book.Author.LastName[0:3],
DOB: book.Author.DOB,
}
}

func (book Book) NoReturn() {
}

func (book Book) TooManyReturn() (string, error) {
return "", nil
}

func (book Book) HasArg(arg string) string {
return arg
}

type person struct {
FirstName string
LastName string
DOB time.Time
}

var _ = Describe("HaveField", 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,
}
})

DescribeTable("traversing the struct works",
func(field string, expected interface{}) {
Ω(book).Should(HaveField(field, expected))
},
Entry("Top-level field with default submatcher", "Title", "Les Miserables"),
Entry("Top-level field with custom submatcher", "Title", ContainSubstring("Les Mis")),
Entry("Nested field", "Author.FirstName", "Victor"),
Entry("Top-level method", "AuthorName()", "Victor Hugo"),
Entry("Nested method", "Author.DOB.Year()", BeNumerically("<", 1900)),
Entry("Traversing past a method", "AbbreviatedAuthor().FirstName", Equal("Vic")),
)

DescribeTable("negation works",
func(field string, expected interface{}) {
Ω(book).ShouldNot(HaveField(field, expected))
},
Entry("Top-level field with default submatcher", "Title", "Les Mis"),
Entry("Top-level field with custom submatcher", "Title", ContainSubstring("Notre Dame")),
Entry("Nested field", "Author.FirstName", "Hugo"),
Entry("Top-level method", "AuthorName()", "Victor M. Hugo"),
Entry("Nested method", "Author.DOB.Year()", BeNumerically(">", 1900)),
)

Describe("when field lookup fails", func() {
It("errors appropriately", func() {
success, err := HaveField("BookName", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "BookName"))

success, err = HaveField("BookName", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "BookName"))

success, err = HaveField("AuthorName", "Victor Hugo").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "AuthorName"))

success, err = HaveField("Title()", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveField could not find method named '%s' in struct of type matchers_test.Book.", "Title()"))

success, err = HaveField("NoReturn()", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(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."))

success, err = HaveField("TooManyReturn()", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(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."))

success, err = HaveField("HasArg()", "Les Miserables").Match(book)
Ω(success).Should(BeFalse())
Ω(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."))

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

success, err = HaveField("Author.Abbreviation", "Vic").Match(book)
Ω(success).Should(BeFalse())
Ω(err.Error()).Should(ContainSubstring("HaveField could not find field named '%s' in struct:", "Abbreviation"))
})
})

Describe("Failure Messages", func() {
It("renders the underlying matcher failure", func() {
matcher := HaveField("Title", "Les Mis")
success, err := matcher.Match(book)
Ω(success).Should(BeFalse())
Ω(err).ShouldNot(HaveOccurred())

msg := matcher.FailureMessage(book)
Ω(msg).Should(Equal("Value for field 'Title' failed to satisfy matcher.\nExpected\n <string>: Les Miserables\nto equal\n <string>: Les Mis"))

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

msg = matcher.NegatedFailureMessage(book)
Ω(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"))
})
})
})

0 comments on commit 3a26311

Please sign in to comment.