1 /* 2 package gcustom provides a simple mechanism for creating custom Gomega matchers 3 */ 4 package gcustom 5 6 import ( 7 "fmt" 8 "reflect" 9 "strings" 10 "text/template" 11 12 "github.com/onsi/gomega/format" 13 ) 14 15 var interfaceType = reflect.TypeOf((*interface{})(nil)).Elem() 16 var errInterface = reflect.TypeOf((*error)(nil)).Elem() 17 18 var defaultTemplate = template.Must(ParseTemplate("{{if .Failure}}Custom matcher failed for:{{else}}Custom matcher succeeded (but was expected to fail) for:{{end}}\n{{.FormattedActual}}")) 19 20 func formatObject(object any, indent ...uint) string { 21 indentation := uint(0) 22 if len(indent) > 0 { 23 indentation = indent[0] 24 } 25 return format.Object(object, indentation) 26 } 27 28 /* 29 ParseTemplate allows you to precompile templates for MakeMatcher's custom matchers. 30 31 Use ParseTemplate if you are concerned about performance and would like to avoid repeatedly parsing failure message templates. The data made available to the template is documented in the WithTemplate() method of CustomGomegaMatcher. 32 33 Once parsed you can pass the template in either as an argument to MakeMatcher(matchFunc, <template>) or using MakeMatcher(matchFunc).WithPrecompiledTemplate(template) 34 */ 35 func ParseTemplate(templ string) (*template.Template, error) { 36 return template.New("template").Funcs(template.FuncMap{ 37 "format": formatObject, 38 }).Parse(templ) 39 } 40 41 /* 42 MakeMatcher builds a Gomega-compatible matcher from a function (the matchFunc). 43 44 matchFunc must return (bool, error) and take a single argument. If you want to perform type-checking yourself pass in a matchFunc of type `func(actual any) (bool, error)`. If you want to only operate on a specific type, pass in `func(actual DesiredType) (bool, error)`; MakeMatcher will take care of checking types for you and notifying the user if they use the matcher with an invalid type. 45 46 MakeMatcher(matchFunc) builds a matcher with generic failure messages that look like: 47 48 Custom matcher failed for: 49 <formatted actual> 50 51 for the positive failure case (i.e. when Expect(actual).To(match) fails) and 52 53 Custom matcher succeeded (but was expected to fail) for: 54 <formatted actual> 55 56 for the negative case (i.e. when Expect(actual).NotTo(match) fails). 57 58 There are two ways to provide a different message. You can either provide a simple message string: 59 60 matcher := MakeMatcher(matchFunc, message) 61 matcher := MakeMatcher(matchFunc).WithMessage(message) 62 63 (where message is of type string) or a template: 64 65 matcher := MakeMatcher(matchFunc).WithTemplate(templateString) 66 67 where templateString is a string that is compiled by WithTemplate into a matcher. Alternatively you can provide a precompiled template like this: 68 69 template, err = gcustom.ParseTemplate(templateString) //use gcustom's ParseTemplate to get some additional functions mixed in 70 matcher := MakeMatcher(matchFunc, template) 71 matcher := MakeMatcher(matchFunc).WithPrecompiled(template) 72 73 When a simple message string is provided the positive failure message will look like: 74 75 Expected: 76 <formatted actual> 77 to <message> 78 79 and the negative failure message will look like: 80 81 Expected: 82 <formatted actual> 83 not to <message> 84 85 A template allows you to have greater control over the message. For more details see the docs for WithTemplate 86 */ 87 func MakeMatcher(matchFunc any, args ...any) CustomGomegaMatcher { 88 t := reflect.TypeOf(matchFunc) 89 if !(t.Kind() == reflect.Func && t.NumIn() == 1 && t.NumOut() == 2 && t.Out(0).Kind() == reflect.Bool && t.Out(1).Implements(errInterface)) { 90 panic("MakeMatcher must be passed a function that takes one argument and returns (bool, error)") 91 } 92 var finalMatchFunc func(actual any) (bool, error) 93 if t.In(0) == interfaceType { 94 finalMatchFunc = matchFunc.(func(actual any) (bool, error)) 95 } else { 96 matchFuncValue := reflect.ValueOf(matchFunc) 97 finalMatchFunc = reflect.MakeFunc(reflect.TypeOf(finalMatchFunc), 98 func(args []reflect.Value) []reflect.Value { 99 actual := args[0].Interface() 100 if actual == nil && reflect.TypeOf(actual) == reflect.TypeOf(nil) { 101 return matchFuncValue.Call([]reflect.Value{reflect.New(t.In(0)).Elem()}) 102 } else if reflect.TypeOf(actual).AssignableTo(t.In(0)) { 103 return matchFuncValue.Call([]reflect.Value{reflect.ValueOf(actual)}) 104 } else { 105 return []reflect.Value{ 106 reflect.ValueOf(false), 107 reflect.ValueOf(fmt.Errorf("Matcher expected actual of type <%s>. Got:\n%s", t.In(0), format.Object(actual, 1))), 108 } 109 } 110 }).Interface().(func(actual any) (bool, error)) 111 } 112 113 matcher := CustomGomegaMatcher{ 114 matchFunc: finalMatchFunc, 115 templateMessage: defaultTemplate, 116 } 117 118 for _, arg := range args { 119 switch v := arg.(type) { 120 case string: 121 matcher = matcher.WithMessage(v) 122 case *template.Template: 123 matcher = matcher.WithPrecompiledTemplate(v) 124 } 125 } 126 127 return matcher 128 } 129 130 // CustomGomegaMatcher is generated by MakeMatcher - you should always use MakeMatcher to construct custom matchers 131 type CustomGomegaMatcher struct { 132 matchFunc func(actual any) (bool, error) 133 templateMessage *template.Template 134 templateData any 135 customFailureMessage func(actual any) string 136 customNegatedFailureMessage func(actual any) string 137 } 138 139 /* 140 WithMessage returns a CustomGomegaMatcher configured with a message to display when failure occurs. Matchers configured this way produce a positive failure message that looks like: 141 142 Expected: 143 <formatted actual> 144 to <message> 145 146 and a negative failure message that looks like: 147 148 Expected: 149 <formatted actual> 150 not to <message> 151 */ 152 func (c CustomGomegaMatcher) WithMessage(message string) CustomGomegaMatcher { 153 return c.WithTemplate("Expected:\n{{.FormattedActual}}\n{{.To}} " + message) 154 } 155 156 /* 157 WithTemplate compiles the passed-in template and returns a CustomGomegaMatcher configured to use that template to generate failure messages. 158 159 Templates are provided the following variables and functions: 160 161 {{.Failure}} - a bool that, if true, indicates this should be a positive failure message, otherwise this should be a negated failure message 162 {{.NegatedFailure}} - a bool that, if true, indicates this should be a negated failure message, otherwise this should be a positive failure message 163 {{.To}} - is set to "to" if this is a positive failure message and "not to" if this is a negated failure message 164 {{.Actual}} - the actual passed in to the matcher 165 {{.FormattedActual}} - a string representing the formatted actual. This can be multiple lines and is always generated with an indentation of 1 166 {{format <object> <optional-indentation}} - a function that allows you to use Gomega's default formatting from within the template. The passed-in <object> is formatted and <optional-indentation> can be set to an integer to control indentation. 167 168 In addition, you can provide custom data to the template by calling WithTemplate(templateString, data) (where data can be anything). This is provided to the template as {{.Data}}. 169 170 Here's a simple example of all these pieces working together: 171 172 func HaveWidget(widget Widget) OmegaMatcher { 173 return MakeMatcher(func(machine Machine) (bool, error) { 174 return machine.HasWidget(widget), nil 175 }).WithTemplate("Expected:\n{{.FormattedActual}}\n{{.To}} have widget named {{.Data.Name}}:\n{{format .Data 1}}", widget) 176 } 177 178 Expect(machine).To(HaveWidget(Widget{Name: "sprocket", Version: 2})) 179 180 Would generate a failure message that looks like: 181 182 Expected: 183 <formatted machine> 184 to have widget named sprocket: 185 <formatted sprocket> 186 */ 187 func (c CustomGomegaMatcher) WithTemplate(templ string, data ...any) CustomGomegaMatcher { 188 return c.WithPrecompiledTemplate(template.Must(ParseTemplate(templ)), data...) 189 } 190 191 /* 192 WithPrecompiledTemplate returns a CustomGomegaMatcher configured to use the passed-in template. The template should be precompiled with gcustom.ParseTemplate(). 193 194 As with WithTemplate() you can provide a single pice of additional data as an optional argument. This is accessed in the template via {{.Data}} 195 */ 196 func (c CustomGomegaMatcher) WithPrecompiledTemplate(templ *template.Template, data ...any) CustomGomegaMatcher { 197 c.templateMessage = templ 198 c.templateData = nil 199 if len(data) > 0 { 200 c.templateData = data[0] 201 } 202 return c 203 } 204 205 /* 206 WithTemplateData() returns a CustomGomegaMatcher configured to provide it's template with the passed-in data. The following are equivalent: 207 208 MakeMatcher(matchFunc).WithTemplate(templateString, data) 209 MakeMatcher(matchFunc).WithTemplate(templateString).WithTemplateData(data) 210 */ 211 func (c CustomGomegaMatcher) WithTemplateData(data any) CustomGomegaMatcher { 212 c.templateData = data 213 return c 214 } 215 216 // Match runs the passed-in match func and satisfies the GomegaMatcher interface 217 func (c CustomGomegaMatcher) Match(actual any) (bool, error) { 218 return c.matchFunc(actual) 219 } 220 221 // FailureMessage generates the positive failure message configured via WithMessage or WithTemplate/WithPrecompiledTemplate 222 // i.e. this is the failure message when Expect(actual).To(match) fails 223 func (c CustomGomegaMatcher) FailureMessage(actual any) string { 224 return c.renderTemplateMessage(actual, true) 225 } 226 227 // NegatedFailureMessage generates the negative failure message configured via WithMessage or WithTemplate/WithPrecompiledTemplate 228 // i.e. this is the failure message when Expect(actual).NotTo(match) fails 229 func (c CustomGomegaMatcher) NegatedFailureMessage(actual any) string { 230 return c.renderTemplateMessage(actual, false) 231 } 232 233 type templateData struct { 234 Failure bool 235 NegatedFailure bool 236 To string 237 FormattedActual string 238 Actual any 239 Data any 240 } 241 242 func (c CustomGomegaMatcher) renderTemplateMessage(actual any, isFailure bool) string { 243 var data templateData 244 formattedActual := format.Object(actual, 1) 245 if isFailure { 246 data = templateData{ 247 Failure: true, 248 NegatedFailure: false, 249 To: "to", 250 FormattedActual: formattedActual, 251 Actual: actual, 252 Data: c.templateData, 253 } 254 } else { 255 data = templateData{ 256 Failure: false, 257 NegatedFailure: true, 258 To: "not to", 259 FormattedActual: formattedActual, 260 Actual: actual, 261 Data: c.templateData, 262 } 263 } 264 b := &strings.Builder{} 265 err := c.templateMessage.Execute(b, data) 266 if err != nil { 267 return fmt.Sprintf("Failed to render failure message template: %s", err.Error()) 268 } 269 return b.String() 270 } 271