Skip to content

Commit

Permalink
First pass at gcustom: a convenience package for making custom matche…
Browse files Browse the repository at this point in the history
…rs. Documentation to follow.
  • Loading branch information
onsi committed Oct 26, 2022
1 parent bf817a4 commit 6a2e51e
Show file tree
Hide file tree
Showing 3 changed files with 359 additions and 0 deletions.
13 changes: 13 additions & 0 deletions gcustom/gcustom_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package gcustom_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestGcustom(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Gcustom Suite")
}
151 changes: 151 additions & 0 deletions gcustom/make_matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package gcustom

import (
"fmt"
"reflect"
"strings"
"text/template"

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

var interfaceType = reflect.TypeOf((*interface{})(nil)).Elem()
var errInterface = reflect.TypeOf((*error)(nil)).Elem()

var defaultTemplate = template.Must(ParseTemplate("{{if .Failure}}Custom matcher failed for:{{else}}Custom matcher succeeded (but was expected to fail) for:{{end}}\n{{.FormattedActual}}"))

func formatObject(object any, indent ...uint) string {
indentation := uint(0)
if len(indent) > 0 {
indentation = indent[0]
}
return format.Object(object, indentation)
}

func ParseTemplate(templ string) (*template.Template, error) {
return template.New("template").Funcs(template.FuncMap{
"format": formatObject,
}).Parse(templ)
}

func MakeMatcher(matchFunc any, args ...any) CustomGomegaMatcher {
t := reflect.TypeOf(matchFunc)
if !(t.Kind() == reflect.Func && t.NumIn() == 1 && t.NumOut() == 2 && t.Out(0).Kind() == reflect.Bool && t.Out(1).Implements(errInterface)) {
panic("MakeMatcher must be passed a function that takes one argument and returns (bool, error)")
}
var finalMatchFunc func(actual any) (bool, error)
if t.In(0) == interfaceType {
finalMatchFunc = matchFunc.(func(actual any) (bool, error))
} else {
matchFuncValue := reflect.ValueOf(matchFunc)
finalMatchFunc = reflect.MakeFunc(reflect.TypeOf(finalMatchFunc),
func(args []reflect.Value) []reflect.Value {
actual := args[0].Interface()
if reflect.TypeOf(actual).AssignableTo(t.In(0)) {
return matchFuncValue.Call([]reflect.Value{reflect.ValueOf(actual)})
} else {
return []reflect.Value{
reflect.ValueOf(false),
reflect.ValueOf(fmt.Errorf("Matcher expected actual of type <%s>. Got:\n%s", t.In(0), format.Object(actual, 1))),
}
}
}).Interface().(func(actual any) (bool, error))
}

matcher := CustomGomegaMatcher{
matchFunc: finalMatchFunc,
templateMessage: defaultTemplate,
}

for _, arg := range args {
switch v := arg.(type) {
case string:
matcher = matcher.WithMessage(v)
case *template.Template:
matcher = matcher.WithPrecompiledTemplate(v)
}
}

return matcher
}

type CustomGomegaMatcher struct {
matchFunc func(actual any) (bool, error)
templateMessage *template.Template
templateData any
customFailureMessage func(actual any) string
customNegatedFailureMessage func(actual any) string
}

func (c CustomGomegaMatcher) WithMessage(message string) CustomGomegaMatcher {
return c.WithTemplate("Expected:\n{{.FormattedActual}}\n{{.To}} " + message)
}

func (c CustomGomegaMatcher) WithTemplate(templ string, data ...any) CustomGomegaMatcher {
return c.WithPrecompiledTemplate(template.Must(ParseTemplate(templ)), data...)
}

func (c CustomGomegaMatcher) WithPrecompiledTemplate(templ *template.Template, data ...any) CustomGomegaMatcher {
c.templateMessage = templ
c.templateData = nil
if len(data) > 0 {
c.templateData = data[0]
}
return c
}

func (c CustomGomegaMatcher) WithTemplateData(data any) CustomGomegaMatcher {
c.templateData = data
return c
}

func (c CustomGomegaMatcher) Match(actual any) (bool, error) {
return c.matchFunc(actual)
}

func (c CustomGomegaMatcher) FailureMessage(actual any) string {
return c.renderTemplateMessage(actual, true)
}

func (c CustomGomegaMatcher) NegatedFailureMessage(actual any) string {
return c.renderTemplateMessage(actual, false)
}

type templateData struct {
Failure bool
NegatedFailure bool
To string
FormattedActual string
Actual any
Data any
}

func (c CustomGomegaMatcher) renderTemplateMessage(actual any, isFailure bool) string {
var data templateData
formattedActual := format.Object(actual, 1)
if isFailure {
data = templateData{
Failure: true,
NegatedFailure: false,
To: "to",
FormattedActual: formattedActual,
Actual: actual,
Data: c.templateData,
}
} else {
data = templateData{
Failure: false,
NegatedFailure: true,
To: "not to",
FormattedActual: formattedActual,
Actual: actual,
Data: c.templateData,
}
}
b := &strings.Builder{}
err := c.templateMessage.Execute(b, data)
if err != nil {
return fmt.Sprintf("Failed to render failure message template: %s", err.Error())
}
return b.String()
}
195 changes: 195 additions & 0 deletions gcustom/make_matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package gcustom_test

import (
"errors"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gcustom"
)

type someType struct {
Name string
}

var _ = Describe("MakeMatcher", func() {
Describe("validating and wrapping the MatchFunc", func() {
DescribeTable("it panics when passed an invalid function", func(f any) {
Expect(func() {
gcustom.MakeMatcher(f)
}).To(PanicWith("MakeMatcher must be passed a function that takes one argument and returns (bool, error)"))
},
Entry("a non-function", "foo"),
Entry("a non-function", 1),
Entry("a function with no input", func() (bool, error) { return false, nil }),
Entry("a function with too many inputs", func(a int, b string) (bool, error) { return false, nil }),
Entry("a function with no outputs", func(a any) {}),
Entry("a function with insufficient outputs", func(a any) bool { return false }),
Entry("a function with insufficient outputs", func(a any) error { return nil }),
Entry("a function with too many outputs", func(a any) (bool, error, string) { return false, nil, "" }),
Entry("a function with the wrong types of outputs", func(a any) (int, error) { return 1, nil }),
Entry("a function with the wrong types of outputs", func(a any) (bool, int) { return false, 1 }),
)

Context("when the match func accepts any actual", func() {
It("always passes in the actual, regardless of type", func() {
var passedIn any
m := gcustom.MakeMatcher(func(a any) (bool, error) {
passedIn = a
return true, nil
})

m.Match(1)
Ω(passedIn).Should(Equal(1))

m.Match("foo")
Ω(passedIn).Should(Equal("foo"))

m.Match(someType{"foo"})
Ω(passedIn).Should(Equal(someType{"foo"}))

c := make(chan bool)
m.Match(c)
Ω(passedIn).Should(Equal(c))
})
})

Context("when the match func accepts a specific type", func() {
It("ensure the type matches before calling func", func() {
var passedIn any
m := gcustom.MakeMatcher(func(a int) (bool, error) {
passedIn = a
return true, nil
})

success, err := m.Match(1)
Ω(success).Should(BeTrue())
Ω(err).ShouldNot(HaveOccurred())
Ω(passedIn).Should(Equal(1))

passedIn = nil
success, err = m.Match(1.2)
Ω(success).Should(BeFalse())
Ω(err).Should(MatchError(ContainSubstring("Matcher expected actual of type <int>. Got:\n <float64>: 1.2")))
Ω(passedIn).Should(BeNil())

m = gcustom.MakeMatcher(func(a someType) (bool, error) {
passedIn = a
return true, nil
})

success, err = m.Match(someType{"foo"})
Ω(success).Should(BeTrue())
Ω(err).ShouldNot(HaveOccurred())
Ω(passedIn).Should(Equal(someType{"foo"}))

passedIn = nil
success, err = m.Match("foo")
Ω(success).Should(BeFalse())
Ω(err).Should(MatchError(ContainSubstring("Matcher expected actual of type <gcustom_test.someType>. Got:\n <string>: foo")))
Ω(passedIn).Should(BeNil())

})
})
})

It("calls the matchFunc and returns whatever it returns when Match is called", func() {
m := gcustom.MakeMatcher(func(a int) (bool, error) {
if a == 0 {
return true, nil
}
if a == 1 {
return false, nil
}
return false, errors.New("bam")
})

Ω(m.Match(0)).Should(BeTrue())
Ω(m.Match(1)).Should(BeFalse())
success, err := m.Match(2)
Ω(success).Should(BeFalse())
Ω(err).Should(MatchError("bam"))
})

Describe("rendering messages", func() {
var m gcustom.CustomGomegaMatcher
BeforeEach(func() {
m = gcustom.MakeMatcher(func(a any) (bool, error) { return false, nil })
})

Context("when no message is configured", func() {
It("renders a simple canned message", func() {
Ω(m.FailureMessage(3)).Should(Equal("Custom matcher failed for:\n <int>: 3"))
Ω(m.NegatedFailureMessage(3)).Should(Equal("Custom matcher succeeded (but was expected to fail) for:\n <int>: 3"))
})
})

Context("when a simple message is configured", func() {
It("tacks that message onto the end of a formatted string", func() {
m = m.WithMessage("have been confabulated")
Ω(m.FailureMessage(3)).Should(Equal("Expected:\n <int>: 3\nto have been confabulated"))
Ω(m.NegatedFailureMessage(3)).Should(Equal("Expected:\n <int>: 3\nnot to have been confabulated"))

m = gcustom.MakeMatcher(func(a any) (bool, error) { return false, nil }, "have been confabulated")
Ω(m.FailureMessage(3)).Should(Equal("Expected:\n <int>: 3\nto have been confabulated"))
Ω(m.NegatedFailureMessage(3)).Should(Equal("Expected:\n <int>: 3\nnot to have been confabulated"))

})
})

Context("when a template is registered", func() {
It("uses that template", func() {
m = m.WithTemplate("{{.Failure}} {{.NegatedFailure}} {{.To}} {{.FormattedActual}} {{.Actual.Name}}")
Ω(m.FailureMessage(someType{"foo"})).Should(Equal("true false to <gcustom_test.someType>: {Name: \"foo\"} foo"))
Ω(m.NegatedFailureMessage(someType{"foo"})).Should(Equal("false true not to <gcustom_test.someType>: {Name: \"foo\"} foo"))

})
})

Context("when a template with custom data is registered", func() {
It("provides that custom data", func() {
m = m.WithTemplate("{{.Failure}} {{.NegatedFailure}} {{.To}} {{.FormattedActual}} {{.Actual.Name}} {{.Data}}", 17)

Ω(m.FailureMessage(someType{"foo"})).Should(Equal("true false to <gcustom_test.someType>: {Name: \"foo\"} foo 17"))
Ω(m.NegatedFailureMessage(someType{"foo"})).Should(Equal("false true not to <gcustom_test.someType>: {Name: \"foo\"} foo 17"))
})

It("provides a mechanism for formatting custom data", func() {
m = m.WithTemplate("{{format .Data}}", 17)

Ω(m.FailureMessage(0)).Should(Equal("<int>: 17"))
Ω(m.NegatedFailureMessage(0)).Should(Equal("<int>: 17"))

m = m.WithTemplate("{{format .Data 1}}", 17)

Ω(m.FailureMessage(0)).Should(Equal(" <int>: 17"))
Ω(m.NegatedFailureMessage(0)).Should(Equal(" <int>: 17"))

})
})

Context("when a precompiled template is registered", func() {
It("uses that template", func() {
templ, err := gcustom.ParseTemplate("{{.Failure}} {{.NegatedFailure}} {{.To}} {{.FormattedActual}} {{.Actual.Name}} {{format .Data}}")
Ω(err).ShouldNot(HaveOccurred())

m = m.WithPrecompiledTemplate(templ, 17)
Ω(m.FailureMessage(someType{"foo"})).Should(Equal("true false to <gcustom_test.someType>: {Name: \"foo\"} foo <int>: 17"))
Ω(m.NegatedFailureMessage(someType{"foo"})).Should(Equal("false true not to <gcustom_test.someType>: {Name: \"foo\"} foo <int>: 17"))
})

It("can also take a template as an argument upon construction", func() {
templ, err := gcustom.ParseTemplate("{{.To}} {{format .Data}}")
Ω(err).ShouldNot(HaveOccurred())
m = gcustom.MakeMatcher(func(a any) (bool, error) { return false, nil }, templ)

Ω(m.FailureMessage(0)).Should(Equal("to <nil>: nil"))
Ω(m.NegatedFailureMessage(0)).Should(Equal("not to <nil>: nil"))

m = m.WithTemplateData(17)
Ω(m.FailureMessage(0)).Should(Equal("to <int>: 17"))
Ω(m.NegatedFailureMessage(0)).Should(Equal("not to <int>: 17"))
})
})
})
})

0 comments on commit 6a2e51e

Please sign in to comment.