diff --git a/matchers.go b/matchers.go index b43235b7d..e4aacc90e 100644 --- a/matchers.go +++ b/matchers.go @@ -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...). diff --git a/matchers/have_field.go b/matchers/have_field.go new file mode 100644 index 000000000..2f1a91634 --- /dev/null +++ b/matchers/have_field.go @@ -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 +} diff --git a/matchers/have_field_test.go b/matchers/have_field_test.go new file mode 100644 index 000000000..aeff14e53 --- /dev/null +++ b/matchers/have_field_test.go @@ -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 : 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 : Les Miserables\nto equal\n : 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 : Les Miserables\nnot to equal\n : Les Miserables")) + }) + }) +})