From c0f60ccc9b9c4b02561b65c2aa436cbea2684fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20Soul=C3=A9?= Date: Thu, 22 Sep 2022 17:56:56 +0200 Subject: [PATCH] feat: add ErrorIs operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maxime Soulé --- README.md | 3 ++ td/cmp_funcs.go | 24 ++++++++- td/example_cmp_test.go | 23 ++++++++ td/example_t_test.go | 23 ++++++++ td/example_test.go | 23 ++++++++ td/t.go | 19 +++++++ td/td_error_is.go | 118 +++++++++++++++++++++++++++++++++++++++++ td/td_error_is_test.go | 102 +++++++++++++++++++++++++++++++++++ td/td_json.go | 1 + tools/gen_funcs.pl | 3 +- 10 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 td/td_error_is.go create mode 100644 td/td_error_is_test.go diff --git a/README.md b/README.md index 442459a4..06a49f6c 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,7 @@ See [FAQ](https://go-testdeep.zetta.rocks/faq/). [`ContainsKey`]: https://go-testdeep.zetta.rocks/operators/containskey/ [`Delay`]: https://go-testdeep.zetta.rocks/operators/delay/ [`Empty`]: https://go-testdeep.zetta.rocks/operators/empty/ +[`ErrorIs`]: https://go-testdeep.zetta.rocks/operators/erroris/ [`First`]: https://go-testdeep.zetta.rocks/operators/first/ [`Grep`]: https://go-testdeep.zetta.rocks/operators/grep/ [`Gt`]: https://go-testdeep.zetta.rocks/operators/gt/ @@ -370,6 +371,7 @@ See [FAQ](https://go-testdeep.zetta.rocks/faq/). [`CmpContains`]: https://go-testdeep.zetta.rocks/operators/contains/#cmpcontains-shortcut [`CmpContainsKey`]: https://go-testdeep.zetta.rocks/operators/containskey/#cmpcontainskey-shortcut [`CmpEmpty`]: https://go-testdeep.zetta.rocks/operators/empty/#cmpempty-shortcut +[`CmpErrorIs`]: https://go-testdeep.zetta.rocks/operators/erroris/#cmperroris-shortcut [`CmpFirst`]: https://go-testdeep.zetta.rocks/operators/first/#cmpfirst-shortcut [`CmpGrep`]: https://go-testdeep.zetta.rocks/operators/grep/#cmpgrep-shortcut [`CmpGt`]: https://go-testdeep.zetta.rocks/operators/gt/#cmpgt-shortcut @@ -433,6 +435,7 @@ See [FAQ](https://go-testdeep.zetta.rocks/faq/). [`T.Contains`]: https://go-testdeep.zetta.rocks/operators/contains/#tcontains-shortcut [`T.ContainsKey`]: https://go-testdeep.zetta.rocks/operators/containskey/#tcontainskey-shortcut [`T.Empty`]: https://go-testdeep.zetta.rocks/operators/empty/#tempty-shortcut +[`T.CmpErrorIs`]: https://go-testdeep.zetta.rocks/operators/erroris/#tcmperroris-shortcut [`T.First`]: https://go-testdeep.zetta.rocks/operators/first/#tfirst-shortcut [`T.Grep`]: https://go-testdeep.zetta.rocks/operators/grep/#tgrep-shortcut [`T.Gt`]: https://go-testdeep.zetta.rocks/operators/gt/#tgt-shortcut diff --git a/td/cmp_funcs.go b/td/cmp_funcs.go index facc8119..810a001d 100644 --- a/td/cmp_funcs.go +++ b/td/cmp_funcs.go @@ -12,7 +12,7 @@ import ( "time" ) -// allOperators lists the 66 operators. +// allOperators lists the 67 operators. // nil means not usable in JSON(). var allOperators = map[string]any{ "All": All, @@ -28,6 +28,7 @@ var allOperators = map[string]any{ "ContainsKey": ContainsKey, "Delay": nil, "Empty": Empty, + "ErrorIs": nil, "First": First, "Grep": Grep, "Gt": Gt, @@ -318,6 +319,27 @@ func CmpEmpty(t TestingT, got any, args ...any) bool { return Cmp(t, got, Empty(), args...) } +// CmpErrorIs is a shortcut for: +// +// td.Cmp(t, got, td.ErrorIs(expected), args...) +// +// See [ErrorIs] for details. +// +// Returns true if the test is OK, false if it fails. +// +// If t is a [*T] then its Config field is inherited. +// +// args... are optional and allow to name the test. This name is +// used in case of failure to qualify the test. If len(args) > 1 and +// the first item of args is a string and contains a '%' rune then +// [fmt.Fprintf] is used to compose the name, else args are passed to +// [fmt.Fprint]. Do not forget it is the name of the test, not the +// reason of a potential failure. +func CmpErrorIs(t TestingT, got any, expected error, args ...any) bool { + t.Helper() + return Cmp(t, got, ErrorIs(expected), args...) +} + // CmpFirst is a shortcut for: // // td.Cmp(t, got, td.First(filter, expectedValue), args...) diff --git a/td/example_cmp_test.go b/td/example_cmp_test.go index 9de99d3a..7b73e689 100644 --- a/td/example_cmp_test.go +++ b/td/example_cmp_test.go @@ -726,6 +726,29 @@ func ExampleCmpEmpty_pointers() { // false } +func ExampleCmpErrorIs() { + t := &testing.T{} + + err1 := fmt.Errorf("failure1") + err2 := fmt.Errorf("failure2: %w", err1) + err3 := fmt.Errorf("failure3: %w", err2) + err := fmt.Errorf("failure4: %w", err3) + + ok := td.CmpErrorIs(t, err, err) + fmt.Println("error is itself:", ok) + + ok = td.CmpErrorIs(t, err, err1) + fmt.Println("error is also err1:", ok) + + ok = td.CmpErrorIs(t, err1, err) + fmt.Println("err1 is err:", ok) + + // Output: + // error is itself: true + // error is also err1: true + // err1 is err: false +} + func ExampleCmpFirst_classic() { t := &testing.T{} diff --git a/td/example_t_test.go b/td/example_t_test.go index 144e41a8..6a69b372 100644 --- a/td/example_t_test.go +++ b/td/example_t_test.go @@ -726,6 +726,29 @@ func ExampleT_Empty_pointers() { // false } +func ExampleT_CmpErrorIs() { + t := td.NewT(&testing.T{}) + + err1 := fmt.Errorf("failure1") + err2 := fmt.Errorf("failure2: %w", err1) + err3 := fmt.Errorf("failure3: %w", err2) + err := fmt.Errorf("failure4: %w", err3) + + ok := t.CmpErrorIs(err, err) + fmt.Println("error is itself:", ok) + + ok = t.CmpErrorIs(err, err1) + fmt.Println("error is also err1:", ok) + + ok = t.CmpErrorIs(err1, err) + fmt.Println("err1 is err:", ok) + + // Output: + // error is itself: true + // error is also err1: true + // err1 is err: false +} + func ExampleT_First_classic() { t := td.NewT(&testing.T{}) diff --git a/td/example_test.go b/td/example_test.go index cbb1ca9f..548b75ac 100644 --- a/td/example_test.go +++ b/td/example_test.go @@ -913,6 +913,29 @@ func ExampleEmpty_pointers() { // false } +func ExampleErrorIs() { + t := &testing.T{} + + err1 := fmt.Errorf("failure1") + err2 := fmt.Errorf("failure2: %w", err1) + err3 := fmt.Errorf("failure3: %w", err2) + err := fmt.Errorf("failure4: %w", err3) + + ok := td.Cmp(t, err, td.ErrorIs(err)) + fmt.Println("error is itself:", ok) + + ok = td.Cmp(t, err, td.ErrorIs(err1)) + fmt.Println("error is also err1:", ok) + + ok = td.Cmp(t, err1, td.ErrorIs(err)) + fmt.Println("err1 is err:", ok) + + // Output: + // error is itself: true + // error is also err1: true + // err1 is err: false +} + func ExampleFirst_classic() { t := &testing.T{} diff --git a/td/t.go b/td/t.go index 9e6e9597..6fc6dc21 100644 --- a/td/t.go +++ b/td/t.go @@ -225,6 +225,25 @@ func (t *T) Empty(got any, args ...any) bool { return t.Cmp(got, Empty(), args...) } +// CmpErrorIs is a shortcut for: +// +// t.Cmp(got, td.ErrorIs(expected), args...) +// +// See [ErrorIs] for details. +// +// Returns true if the test is OK, false if it fails. +// +// args... are optional and allow to name the test. This name is +// used in case of failure to qualify the test. If len(args) > 1 and +// the first item of args is a string and contains a '%' rune then +// [fmt.Fprintf] is used to compose the name, else args are passed to +// [fmt.Fprint]. Do not forget it is the name of the test, not the +// reason of a potential failure. +func (t *T) CmpErrorIs(got any, expected error, args ...any) bool { + t.Helper() + return t.Cmp(got, ErrorIs(expected), args...) +} + // First is a shortcut for: // // t.Cmp(got, td.First(filter, expectedValue), args...) diff --git a/td/td_error_is.go b/td/td_error_is.go new file mode 100644 index 00000000..ab6733eb --- /dev/null +++ b/td/td_error_is.go @@ -0,0 +1,118 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package td + +import ( + "errors" + "fmt" + "reflect" + + "github.com/maxatome/go-testdeep/internal/ctxerr" + "github.com/maxatome/go-testdeep/internal/dark" + "github.com/maxatome/go-testdeep/internal/types" +) + +type tdErrorIs struct { + baseOKNil + expected error +} + +var _ TestDeep = &tdErrorIs{} + +func errorToRawString(err error) types.RawString { + if err == nil { + return "nil" + } + return types.RawString(fmt.Sprintf("(%[1]T) %[1]q", err)) +} + +// summary(ErrorIs): checks the data is an error and matches a wrapped error +// input(ErrorIs): if(error) + +// ErrorIs operator reports whether any error in an error's chain +// matches expected. +// +// _, err := os.Open("/unknown/file") +// td.Cmp(t, err, os.ErrNotExist) // fails +// td.Cmp(t, err, td.ErrorIs(os.ErrNotExist)) // succeeds +// +// err1 := fmt.Errorf("failure1") +// err2 := fmt.Errorf("failure2: %w", err1) +// err3 := fmt.Errorf("failure3: %w", err2) +// err := fmt.Errorf("failure4: %w", err3) +// td.Cmp(t, err, td.ErrorIs(err)) // succeeds +// td.Cmp(t, err, td.ErrorIs(err1)) // succeeds +// td.Cmp(t, err1, td.ErrorIs(err)) // fails +// +// Behind the scene it uses [errors.Is] function. +// +// Note that like [errors.Is], expected can be nil: in this case the +// comparison succeeds when got is nil too. +// +// See also [CmpError] and [CmpNoError]. +func ErrorIs(expected error) TestDeep { + return &tdErrorIs{ + baseOKNil: newBaseOKNil(3), + expected: expected, + } +} + +func (e *tdErrorIs) Match(ctx ctxerr.Context, got reflect.Value) *ctxerr.Error { + // nil case + if !got.IsValid() { + // Special case + if e.expected == nil { + return nil + } + + if ctx.BooleanError { + return ctxerr.BooleanError + } + return ctx.CollectError(&ctxerr.Error{ + Message: "nil value", + Got: types.RawString("nil"), + Expected: types.RawString("anything implementing error interface"), + }) + } + + gotIf, ok := dark.GetInterface(got, true) + if !ok { + return ctx.CollectError(ctx.CannotCompareError()) + } + + gotErr, ok := gotIf.(error) + if !ok { + if ctx.BooleanError { + return ctxerr.BooleanError + } + return ctx.CollectError(&ctxerr.Error{ + Message: got.Type().String() + " does not implement error interface", + Got: gotIf, + Expected: types.RawString("anything implementing error interface"), + }) + } + + if errors.Is(gotErr, e.expected) { + return nil + } + + if ctx.BooleanError { + return ctxerr.BooleanError + } + return ctx.CollectError(&ctxerr.Error{ + Message: "is not the error", + Got: errorToRawString(gotErr), + Expected: errorToRawString(e.expected), + }) +} + +func (e *tdErrorIs) String() string { + if e.expected == nil { + return "ErrorIs(nil)" + } + return "ErrorIs(" + e.expected.Error() + ")" +} diff --git a/td/td_error_is_test.go b/td/td_error_is_test.go new file mode 100644 index 00000000..7b6600c6 --- /dev/null +++ b/td/td_error_is_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2022, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package td_test + +import ( + "fmt" + "testing" + + "github.com/maxatome/go-testdeep/internal/dark" + "github.com/maxatome/go-testdeep/internal/test" + "github.com/maxatome/go-testdeep/td" +) + +func TestErrorIs(t *testing.T) { + insideErr1 := fmt.Errorf("failure1") + insideErr2 := fmt.Errorf("failure2: %w", insideErr1) + insideErr3 := fmt.Errorf("failure3: %w", insideErr2) + err := fmt.Errorf("failure4: %w", insideErr3) + + checkOK(t, err, td.ErrorIs(err)) + checkOK(t, err, td.ErrorIs(insideErr3)) + checkOK(t, err, td.ErrorIs(insideErr2)) + checkOK(t, err, td.ErrorIs(insideErr1)) + checkOK(t, nil, td.ErrorIs(nil)) + + var errNil error + checkOK(t, &errNil, td.Ptr(td.ErrorIs(nil))) + + checkError(t, nil, td.ErrorIs(insideErr1), + expectedError{ + Message: mustBe("nil value"), + Path: mustBe("DATA"), + Got: mustBe("nil"), + Expected: mustBe("anything implementing error interface"), + }) + + checkError(t, 45, td.ErrorIs(insideErr1), + expectedError{ + Message: mustBe("int does not implement error interface"), + Path: mustBe("DATA"), + Got: mustBe("45"), + Expected: mustBe("anything implementing error interface"), + }) + + checkError(t, 45, td.ErrorIs(fmt.Errorf("another")), + expectedError{ + Message: mustBe("int does not implement error interface"), + Path: mustBe("DATA"), + Got: mustBe("45"), + Expected: mustBe("anything implementing error interface"), + }) + + checkError(t, err, td.ErrorIs(fmt.Errorf("another")), + expectedError{ + Message: mustBe("is not the error"), + Path: mustBe("DATA"), + Got: mustBe(`(*fmt.wrapError) "failure4: failure3: failure2: failure1"`), + Expected: mustBe(`(*errors.errorString) "another"`), + }) + + checkError(t, err, td.ErrorIs(nil), + expectedError{ + Message: mustBe("is not the error"), + Path: mustBe("DATA"), + Got: mustBe(`(*fmt.wrapError) "failure4: failure3: failure2: failure1"`), + Expected: mustBe(`nil`), + }) + + type private struct{ err error } + got := private{err: err} + for _, expErr := range []error{err, insideErr3} { + expected := td.Struct(private{}, td.StructFields{"err": td.ErrorIs(expErr)}) + if dark.UnsafeDisabled { + checkError(t, got, expected, + expectedError{ + Message: mustBe("cannot compare"), + Path: mustBe("DATA.err"), + Summary: mustBe("unexported field that cannot be overridden"), + }) + } else { + checkOK(t, got, expected) + } + } + + if !dark.UnsafeDisabled { + got = private{} + checkOK(t, got, td.Struct(private{}, td.StructFields{"err": td.ErrorIs(nil)})) + } + + // + // String + test.EqualStr(t, td.ErrorIs(insideErr1).String(), "ErrorIs(failure1)") + test.EqualStr(t, td.ErrorIs(nil).String(), "ErrorIs(nil)") +} + +func TestErrorIsTypeBehind(t *testing.T) { + equalTypes(t, td.ErrorIs(fmt.Errorf("another")), nil) +} diff --git a/td/td_json.go b/td/td_json.go index d90cdd80..2907cf28 100644 --- a/td/td_json.go +++ b/td/td_json.go @@ -34,6 +34,7 @@ var forbiddenOpsInJSON = map[string]string{ "Catch": "", "Code": "", "Delay": "", + "ErrorIs": "", "Isa": "", "JSON": "literal JSON", "Lax": "", diff --git a/tools/gen_funcs.pl b/tools/gen_funcs.pl index c4c7802e..0f6a3d35 100755 --- a/tools/gen_funcs.pl +++ b/tools/gen_funcs.pl @@ -112,7 +112,8 @@ my %SMUGGLER_OPERATORS; # These operators should be renamed when used as *T method -my %RENAME_METHOD = (Lax => 'CmpLax'); +my %RENAME_METHOD = (Lax => 'CmpLax', + ErrorIs => 'CmpErrorIs'); # These operators do not have *T method nor Cmp shortcut my %ONLY_OPERATORS = map { $_ => 1 } qw(Catch Delay Ignore Tag);