Skip to content

Commit

Permalink
feat: add ErrorIs operator
Browse files Browse the repository at this point in the history
Signed-off-by: Maxime Soulé <btik-git@scoubidou.com>
  • Loading branch information
maxatome committed Oct 9, 2022
1 parent d5df26b commit c0f60cc
Show file tree
Hide file tree
Showing 10 changed files with 337 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion td/cmp_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +28,7 @@ var allOperators = map[string]any{
"ContainsKey": ContainsKey,
"Delay": nil,
"Empty": Empty,
"ErrorIs": nil,
"First": First,
"Grep": Grep,
"Gt": Gt,
Expand Down Expand Up @@ -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...)
Expand Down
23 changes: 23 additions & 0 deletions td/example_cmp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
23 changes: 23 additions & 0 deletions td/example_t_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})

Expand Down
23 changes: 23 additions & 0 deletions td/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
19 changes: 19 additions & 0 deletions td/t.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
118 changes: 118 additions & 0 deletions td/td_error_is.go
Original file line number Diff line number Diff line change
@@ -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() + ")"
}
Loading

0 comments on commit c0f60cc

Please sign in to comment.