diff --git a/README.md b/README.md index 90434e6..443e043 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Expect(req).To(be_http.Request( be_json.Matcher( be_json.JsonAsReader, be_json.HaveKeyValue("hello", "world"), - be_json.HaveKeyValue("n", be_reflected.AsIntish()), + be_json.HaveKeyValue("n", be_reflected.AsInteger()), be_json.HaveKeyValue("ids", be_reflected.AsSliceOf[string]), be_json.HaveKeyValue("details", And( be_reflected.AsObjects(), @@ -121,7 +121,7 @@ types.
[See detailed docs](be_reflected/README.md) #### Data Type Matchers based on reflect.Kind -`AsString`, `AsBytes`, `AsNumeric`, `AsNumericString`, `AsIntish`, `AsIntishString`, `AsFloatish`, `AsFloatishString`, +`AsString`, `AsBytes`, `AsNumeric`, `AsNumericString`, `AsInteger`, `AsIntegerString`, `AsFloat`, `AsFloatishString`, #### Interface Matchers based on reflect.Kind diff --git a/be_math/README.md b/be_math/README.md index 1b50659..8331a4a 100644 --- a/be_math/README.md +++ b/be_math/README.md @@ -34,8 +34,7 @@ DivisibleBy succeeds if actual is numerically divisible by the passed-in value. ```go func Even() types.BeMatcher ``` -Even succeeds if actual is an even numeric value. todo: test if failure message -is OK +Even succeeds if actual is an even numeric value. #### func GreaterThan @@ -128,8 +127,7 @@ Negative succeeds if actual is a negative numeric value. ```go func Odd() types.BeMatcher ``` -Odd succeeds if actual is an odd numeric value. todo: test if failure message is -OK +Odd succeeds if actual is an odd numeric value. #### func Positive diff --git a/be_math/matchers_math.go b/be_math/matchers_math.go index 4b7c7a0..3a85476 100644 --- a/be_math/matchers_math.go +++ b/be_math/matchers_math.go @@ -53,51 +53,62 @@ func InRange(from any, fromInclusive bool, until any, untilInclusive bool) types } else { group[1] = Lt(until) } - return psi_matchers.NewAllMatcher(cast.AsSliceOfAny(group)...) + + // For compiling a nice failure message we better use `[from, until)` format + leftBracket, rightBracket := "(", ")" + if fromInclusive { + leftBracket = "[" + } + if untilInclusive { + rightBracket = "]" + } + + return WithCustomMessage( + psi_matchers.NewAllMatcher(cast.AsSliceOfAny(group)...), + fmt.Sprintf("be in range %s%v, %v%s", leftBracket, from, until, rightBracket), + ) } // Odd succeeds if actual is an odd numeric value. -// todo: test if failure message is OK func Odd() types.BeMatcher { - return Psi( - be_reflected.AsIntish(), + return WithCustomMessage(psi_matchers.NewAllMatcher( + be_reflected.AsInteger(), WithFallibleTransform(func(actual any) any { return int(cast.AsFloat(actual))%2 != 0 }, gomega.BeTrue()), - ) + ), "be an odd number") } // Even succeeds if actual is an even numeric value. -// todo: test if failure message is OK func Even() types.BeMatcher { - return Psi( - be_reflected.AsIntish(), + return WithCustomMessage(psi_matchers.NewAllMatcher( + be_reflected.AsInteger(), WithFallibleTransform(func(actual any) any { return int(cast.AsFloat(actual))%2 == 0 }, gomega.BeTrue()), - ) + ), "be an even number") } // Negative succeeds if actual is a negative numeric value. func Negative() types.BeMatcher { - return Psi(gcustom.MakeMatcher(LessThan(0.0).Match, "be negative")) + return WithCustomMessage(LessThan(0.0), "be negative") } // Positive succeeds if actual is a positive numeric value. func Positive() types.BeMatcher { - return Psi(gcustom.MakeMatcher(GreaterThan(0.0).Match, "be positive")) + return WithCustomMessage(GreaterThan(0.0), "be positive") } // Zero succeeds if actual is numerically equal to zero. // Any type of int/float will work for comparison. func Zero() types.BeMatcher { - return Psi(gcustom.MakeMatcher(Approx(0, 0).Match, "be zero")) + return WithCustomMessage(Approx(0, 0), "be zero") } // ApproxZero succeeds if actual is numerically approximately equal to zero // Any type of int/float will work for comparison. func ApproxZero() types.BeMatcher { - return Psi(gcustom.MakeMatcher(Approx(0, 1e-8).Match, "be approximately zero")) + return WithCustomMessage(Approx(0, 1e-8), "be approximately zero") } // Integral succeeds if actual is an integral float, meaning it has zero decimal places. diff --git a/be_math/matchers_math_test.go b/be_math/matchers_math_test.go index 6f57ae8..50af90c 100644 --- a/be_math/matchers_math_test.go +++ b/be_math/matchers_math_test.go @@ -5,14 +5,20 @@ import ( "github.com/expectto/be/types" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "strings" ) var _ = Describe("BeMath", func() { DescribeTable("should positively match", func(matcher types.BeMatcher, actual any) { + // check gomega-compatible matching: success, err := matcher.Match(actual) Expect(err).Should(Succeed()) Expect(success).To(BeTrue()) + + // check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeTrue()) }, Entry("10 GreaterThan 5", be_math.GreaterThan(5), 10), Entry("10 GreaterThan 5 (alias)", be_math.Gt(5), 10), @@ -71,9 +77,14 @@ var _ = Describe("BeMath", func() { ) DescribeTable("should negatively match", func(matcher types.BeMatcher, actual any) { + // check gomega-compatible matching: success, err := matcher.Match(actual) Expect(err).Should(Succeed()) Expect(success).To(BeFalse()) + + // check gomock-compatible matching: + success = matcher.Matches(actual) + Expect(success).To(BeFalse()) }, Entry("5 is not GreaterThan 10", be_math.GreaterThan(10), 5), Entry("5 is not GreaterThan 5 (alias)", be_math.Gt(5), 5), @@ -104,11 +115,13 @@ var _ = Describe("BeMath", func() { Entry("4 is not an odd number", be_math.Odd(), 4), Entry("-2 is not an odd number", be_math.Odd(), -2), Entry("-4 is not an odd number", be_math.Odd(), -4), + Entry("floats can't be matched as odd numbers", be_math.Odd(), 1.5), Entry("3 is not an even number", be_math.Even(), 3), Entry("7 is not an even number", be_math.Even(), 7), Entry("-3 is not an even number", be_math.Even(), -3), Entry("-7 is not an even number", be_math.Even(), -7), + Entry("floats can't be matched as even numbers", be_math.Even(), 1.5), Entry("5 is not a negative number", be_math.Negative(), 5), Entry("8.5 is not a negative number", be_math.Negative(), 8.5), @@ -127,11 +140,27 @@ var _ = Describe("BeMath", func() { ) DescribeTable("should return a valid failure message", func(matcher types.BeMatcher, actual any, message string) { - Expect(matcher.FailureMessage(actual)).To(Equal(message)) + // FailureMessage is considered to be called after matching: + _, _ = matcher.Match(actual) + + failureMessage := matcher.FailureMessage(actual) + Expect(failureMessage).To(Equal(message)) + + // in all our matchers negated failure messages are simply `to be` => `not to be` + Expect(matcher.NegatedFailureMessage(actual)).To(Equal( + strings.Replace(failureMessage, "\nto be ", "\nnot to be ", 1), + )) }, + // Example of entry where FailureMessage is simply inherited from gomega's underlying matching Entry("5 is not GreaterThan 10", be_math.GreaterThan(10), 5, "Expected\n : 5\nto be >\n : 10"), - Entry("10 is not divisible by 3", be_math.DivisibleBy(3), 10, "Expected:\n : 10\nto be divisible by 3"), + // Examples of entry with custom message (gcustom.MakeMatcher matching) + Entry("10 is not divisible by 3", be_math.DivisibleBy(3), 10, "Expected:\n : 10\nto be divisible by 3"), Entry("0.1 is not zero", be_math.Zero(), 0.1, "Expected:\n : 0.1\nto be zero"), + + // Examples of entry on complex Psi matchers (chaining + transform) + Entry("float is not odd", be_math.Odd(), 12.5, "Expected:\n : 12.5\nto be an odd number"), + Entry("8 is not odd", be_math.Odd(), 8, "Expected:\n : 8\nto be an odd number"), + Entry("8 (uint) is not odd", be_math.Odd(), uint(8), "Expected:\n : 8\nto be an odd number"), ) }) diff --git a/be_reflected/README.md b/be_reflected/README.md index c46dc44..98b2c0e 100644 --- a/be_reflected/README.md +++ b/be_reflected/README.md @@ -3,7 +3,10 @@ import "github.com/expectto/be/be_reflected" Package be_reflected provides Be matchers that use reflection, enabling -expressive assertions on values' reflect kinds and types. +expressive assertions on values' reflect kinds and types. It consists of several +"core" matchers e.g. AsKind / AssignableTo / Implementing And many other +matchers that are made on-top on core ones. E.g. AsFunc / AsString / AsNumber / +### etc ## Usage @@ -29,20 +32,20 @@ func AsFinalPointer() types.BeMatcher AsFinalPointer succeeds if the actual value is a final pointer, meaning it's a pointer to a non-pointer type. -#### func AsFloatish +#### func AsFloat ```go -func AsFloatish() types.BeMatcher +func AsFloat() types.BeMatcher ``` -AsFloatish succeeds if actual is a numeric value that represents a -floating-point value. +AsFloat succeeds if actual is a numeric value that represents a floating-point +value. -#### func AsFloatishString +#### func AsFloatString ```go -func AsFloatishString() types.BeMatcher +func AsFloatString() types.BeMatcher ``` -AsFloatishString succeeds if actual is a string that can be parsed into a valid +AsFloatString succeeds if actual is a string that can be parsed into a valid floating-point value. #### func AsFunc @@ -52,20 +55,20 @@ func AsFunc() types.BeMatcher ``` AsFunc succeeds if actual is of kind reflect.Func. -#### func AsIntish +#### func AsInteger ```go -func AsIntish() types.BeMatcher +func AsInteger() types.BeMatcher ``` -AsIntish succeeds if actual is a numeric value that represents an integer (from +AsInteger succeeds if actual is a numeric value that represents an integer (from reflect.Int up to reflect.Uint64). -#### func AsIntishString +#### func AsIntegerString ```go -func AsIntishString() types.BeMatcher +func AsIntegerString() types.BeMatcher ``` -AsIntishString succeeds if actual is a string that can be parsed into a valid +AsIntegerString succeeds if actual is a string that can be parsed into a valid integer value. #### func AsKind @@ -83,12 +86,12 @@ func AsMap() types.BeMatcher ``` AsMap succeeds if actual is of kind reflect.Map. -#### func AsNumeric +#### func AsNumber ```go -func AsNumeric() types.BeMatcher +func AsNumber() types.BeMatcher ``` -AsNumeric succeeds if actual is a numeric value, supporting various integer +AsNumber succeeds if actual is a numeric value, supporting various integer kinds: reflect.Int, ... reflect.Int64, and floating-point kinds: reflect.Float32, reflect.Float64 diff --git a/be_reflected/matchers_reflected.go b/be_reflected/matchers_reflected.go index 2015153..fada6c2 100644 --- a/be_reflected/matchers_reflected.go +++ b/be_reflected/matchers_reflected.go @@ -1,5 +1,7 @@ // Package be_reflected provides Be matchers that use reflection, // enabling expressive assertions on values' reflect kinds and types. +// It consists of several "core" matchers e.g. AsKind / AssignableTo / Implementing +// And many other matchers that are made on-top on core ones. E.g. AsFunc / AsString / AsNumber / etc package be_reflected import ( @@ -7,6 +9,7 @@ import ( "github.com/expectto/be/internal/cast" . "github.com/expectto/be/internal/psi" "github.com/expectto/be/internal/psi_matchers" + reflect2 "github.com/expectto/be/internal/reflect" "github.com/expectto/be/types" "github.com/onsi/gomega" "io" @@ -23,42 +26,42 @@ func AssignableTo[T any]() types.BeMatcher { return psi_matchers.NewAssignableTo // Implementing succeeds if actual implements the specified interface type T. func Implementing[T any]() types.BeMatcher { return psi_matchers.NewImplementsMatcher[T]() } +// Following matchers below are nice syntax-sugar, pretty usages of core matchers above: + // AsFunc succeeds if actual is of kind reflect.Func. -func AsFunc() types.BeMatcher { return AsKind(reflect.Func) } +func AsFunc() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Func), "be a func") } // AsChan succeeds if actual is of kind reflect.Chan. -func AsChan() types.BeMatcher { return AsKind(reflect.Chan) } +func AsChan() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Chan), "be a channel") } // AsPointer succeeds if the actual value is a pointer. -func AsPointer() types.BeMatcher { return AsKind(reflect.Pointer) } +func AsPointer() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Pointer), "be a pointer") } // AsFinalPointer succeeds if the actual value is a final pointer, meaning it's a pointer to a non-pointer type. func AsFinalPointer() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsPointer(), WithFallibleTransform(func(actual any) any { return reflect.ValueOf(actual).Elem() }, psi_matchers.NewNotMatcher(AsPointer())), - }} + ), "be a final pointer") } // AsStruct succeeds if actual is of kind reflect.Struct. -func AsStruct() types.BeMatcher { return AsKind(reflect.Struct) } +func AsStruct() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Struct), "be a struct") } // AsPointerToStruct succeeds if actual is a pointer to a struct. func AsPointerToStruct() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsPointer(), WithFallibleTransform(func(actual any) any { return reflect.ValueOf(actual).Elem() }, AsStruct()), - }} + ), "be a pointer to a struct") } // AsSlice succeeds if actual is of kind reflect.Slice. -func AsSlice() types.BeMatcher { - return AsKind(reflect.Slice) -} +func AsSlice() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Slice), "be a slice") } // AsPointerToSlice succeeds if actual is a pointer to a slice. func AsPointerToSlice() types.BeMatcher { @@ -73,114 +76,118 @@ func AsPointerToSlice() types.BeMatcher { // AsSliceOf succeeds if actual is of kind reflect.Slice and each element of the slice // is assignable to the specified type T. func AsSliceOf[T any]() types.BeMatcher { - return Psi( + return WithCustomMessage(psi_matchers.NewAllMatcher( AsKind(reflect.Slice), gomega.HaveEach(AssignableTo[T]()), - ) + ), "be a slice of "+reflect2.TypeFor[T]().String()) } // AsMap succeeds if actual is of kind reflect.Map. -func AsMap() types.BeMatcher { return AsKind(reflect.Map) } +func AsMap() types.BeMatcher { return WithCustomMessage(AsKind(reflect.Map), "be a map") } // AsPointerToMap succeeds if actual is a pointer to a map. func AsPointerToMap() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsPointer(), WithFallibleTransform(func(actual any) any { return reflect.ValueOf(actual).Elem() }, AsMap()), - }} + ), "be a pointer to a map") } // AsObject is more specific than AsMap. It checks if the given `actual` value is a map with string keys // and values of any type. This is particularly useful in the context of BeJson matcher, // where the term 'Object' aligns with JSON notation. func AsObject() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsKind(reflect.Map), AssignableTo[map[string]any](), - }} + ), "be an object") } func AsObjects() types.BeMatcher { - return AsSliceOf[map[string]any]() + return WithCustomMessage(AsSliceOf[map[string]any](), "be objects") } // AsPointerToObject succeeds if actual is a pointer to a value that matches AsObject after applying dereference. func AsPointerToObject() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsPointer(), WithFallibleTransform(func(actual any) any { return reflect.ValueOf(actual).Elem() }, AsObject()), - }} + ), "be a pointer to an object") } // AsReader succeeds if actual implements the io.Reader interface. -func AsReader() types.BeMatcher { return Implementing[io.Reader]() } +func AsReader() types.BeMatcher { + return WithCustomMessage(Implementing[io.Reader](), "implement io.Reader interface") +} // AsStringer succeeds if actual implements the fmt.Stringer interface. -func AsStringer() types.BeMatcher { return Implementing[fmt.Stringer]() } +func AsStringer() types.BeMatcher { + return WithCustomMessage(Implementing[fmt.Stringer](), "implement fmt.Stringer interface") +} // AsString succeeds if actual is of kind reflect.String. -func AsString() types.BeMatcher { return AsKind(reflect.String) } +func AsString() types.BeMatcher { return WithCustomMessage(AsKind(reflect.String), "be a string") } // AsBytes succeeds if actual is assignable to a slice of bytes ([]byte). -func AsBytes() types.BeMatcher { return AssignableTo[[]byte]() } +func AsBytes() types.BeMatcher { return WithCustomMessage(AssignableTo[[]byte](), "be bytes") } -// AsNumeric succeeds if actual is a numeric value, supporting various +// AsNumber succeeds if actual is a numeric value, supporting various // integer kinds: reflect.Int, ... reflect.Int64, // and floating-point kinds: reflect.Float32, reflect.Float64 -func AsNumeric() types.BeMatcher { - return AsKind( +func AsNumber() types.BeMatcher { + return WithCustomMessage(AsKind( gomega.BeNumerically(">=", reflect.Int), gomega.BeNumerically("<=", reflect.Float64), - ) + ), "be a number") } // AsNumericString succeeds if actual is a string that can be parsed into a valid numeric value. func AsNumericString() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ + return WithCustomMessage(psi_matchers.NewAllMatcher( AsString(), WithFallibleTransform(func(actual any) any { _, err := strconv.ParseFloat(cast.AsString(actual), 64) return err == nil }, gomega.BeTrue()), - }} + ), "be a numeric string") } -// AsIntish succeeds if actual is a numeric value that represents an integer (from reflect.Int up to reflect.Uint64). -func AsIntish() types.BeMatcher { - return AsKind( +// AsInteger succeeds if actual is a numeric value that represents an integer (from reflect.Int up to reflect.Uint64). +func AsInteger() types.BeMatcher { + return WithCustomMessage(AsKind( gomega.BeNumerically(">=", reflect.Int), gomega.BeNumerically("<=", reflect.Uint64), - ) + ), "be an integer value") } -// AsIntishString succeeds if actual is a string that can be parsed into a valid integer value. -func AsIntishString() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ +// AsIntegerString succeeds if actual is a string that can be parsed into a valid integer value. +func AsIntegerString() types.BeMatcher { + return WithCustomMessage(psi_matchers.NewAllMatcher( AsString(), WithFallibleTransform(func(actual any) any { _, err := strconv.ParseInt(cast.AsString(actual), 10, 64) return err == nil }, gomega.BeTrue()), - }} + ), "be an integer-ish string") } -// AsFloatish succeeds if actual is a numeric value that represents a floating-point value. -func AsFloatish() types.BeMatcher { - return AsKind( +// AsFloat succeeds if actual is a numeric value that represents a floating-point value. +func AsFloat() types.BeMatcher { + return WithCustomMessage(AsKind( gomega.BeNumerically(">=", reflect.Float32), gomega.BeNumerically("<=", reflect.Float64), - ) + ), "be a float value") } -// AsFloatishString succeeds if actual is a string that can be parsed into a valid floating-point value. -func AsFloatishString() types.BeMatcher { - return &psi_matchers.AllMatcher{Matchers: []types.BeMatcher{ +// AsFloatString succeeds if actual is a string that can be parsed into a valid floating-point value. +func AsFloatString() types.BeMatcher { + return WithCustomMessage(psi_matchers.NewAllMatcher( AsString(), WithFallibleTransform(func(actual any) any { _, err := strconv.ParseFloat(cast.AsString(actual), 64) return err == nil }, gomega.BeTrue()), - }} + ), "be a float-ish string") } diff --git a/be_reflected/matchers_reflected_test.go b/be_reflected/matchers_reflected_test.go index 94e34da..8949396 100644 --- a/be_reflected/matchers_reflected_test.go +++ b/be_reflected/matchers_reflected_test.go @@ -10,6 +10,8 @@ import ( "reflect" ) +// TODO: unify tests. Let's make all tests like in `be_math` + var _ = Describe("MatchersReflected", func() { Context("AsKind", func() { DescribeTable("should match kind", func(actual interface{}, expected reflect.Kind) { diff --git a/examples/examples_be_http_test.go b/examples/examples_be_http_test.go index 858642d..7f4a094 100644 --- a/examples/examples_be_http_test.go +++ b/examples/examples_be_http_test.go @@ -80,8 +80,8 @@ var _ = Describe("matchers_http", func() { be_json.Matcher( be_json.JsonAsReader, be_json.HaveKeyValue("hello", "world"), - // TODO: AsIntish should work here, but it's not (as from payload via string, it's float) - be_json.HaveKeyValue("n", be_reflected.AsFloatish()), // any int number + // TODO: AsInteger should work here, but it's not (as from payload via string, it's float) + be_json.HaveKeyValue("n", be_reflected.AsFloat()), // any int number // TODO: fix me //be_json.HaveKeyValue("ids", be_reflected.AsSliceOf[string]), be_json.HaveKeyValue("details", And( diff --git a/go.mod b/go.mod index 89aa634..ee7ff79 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/expectto/be -go 1.22 +go 1.21 require ( github.com/IGLOU-EU/go-wildcard v1.0.3 // latest diff --git a/internal/psi/helpers.go b/internal/psi/helpers.go index e5e810f..dddfc3c 100644 --- a/internal/psi/helpers.go +++ b/internal/psi/helpers.go @@ -3,6 +3,7 @@ package psi import ( "github.com/expectto/be/types" "github.com/onsi/gomega" + "github.com/onsi/gomega/gcustom" ) // IsMatcher returns true if given input is either Omega or Gomock or a Psi matcher @@ -28,3 +29,9 @@ func AsMatcher(m any) types.BeMatcher { return FromGomega(gomega.Equal(t)) } } + +// WithCustomMessage is a wrapper for gcustom.MakeMatcher +// todo: make `v any` so we can check here if it's types.BeMatcher of match-func +func WithCustomMessage(v types.BeMatcher, message string) types.BeMatcher { + return Psi(gcustom.MakeMatcher(v.Match, message)) +} diff --git a/internal/psi_matchers/all_matcher.go b/internal/psi_matchers/all_matcher.go index ce013c2..013a83f 100644 --- a/internal/psi_matchers/all_matcher.go +++ b/internal/psi_matchers/all_matcher.go @@ -66,3 +66,6 @@ func (m *AllMatcher) String() string { } // todo: AllMatcher.MatchMayChangeInTheFuture + +// todo: will be very nice if failure message will be slightly different +// depending on which one matcher inside AndGroup fails