Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ErrorIs): becomes a smuggler operator with errors.As feature #242

Merged
merged 1 commit into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions td/cmp_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ func CmpEmpty(t TestingT, got any, args ...any) bool {

// CmpErrorIs is a shortcut for:
//
// td.Cmp(t, got, td.ErrorIs(expected), args...)
// td.Cmp(t, got, td.ErrorIs(expectedError), args...)
//
// See [ErrorIs] for details.
//
Expand All @@ -335,9 +335,9 @@ func CmpEmpty(t TestingT, got any, args ...any) bool {
// [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 {
func CmpErrorIs(t TestingT, got, expectedError any, args ...any) bool {
t.Helper()
return Cmp(t, got, ErrorIs(expected), args...)
return Cmp(t, got, ErrorIs(expectedError), args...)
}

// CmpFirst is a shortcut for:
Expand Down
6 changes: 3 additions & 3 deletions td/t.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func (t *T) Empty(got any, args ...any) bool {

// CmpErrorIs is a shortcut for:
//
// t.Cmp(got, td.ErrorIs(expected), args...)
// t.Cmp(got, td.ErrorIs(expectedError), args...)
//
// See [ErrorIs] for details.
//
Expand All @@ -239,9 +239,9 @@ func (t *T) Empty(got any, args ...any) bool {
// [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 {
func (t *T) CmpErrorIs(got, expectedError any, args ...any) bool {
t.Helper()
return t.Cmp(got, ErrorIs(expected), args...)
return t.Cmp(got, ErrorIs(expectedError), args...)
}

// First is a shortcut for:
Expand Down
113 changes: 94 additions & 19 deletions td/td_error_is.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
)

type tdErrorIs struct {
baseOKNil
expected error
tdSmugglerBase
typeBehind reflect.Type
}

var _ TestDeep = &tdErrorIs{}
Expand All @@ -33,8 +33,8 @@ func errorToRawString(err error) types.RawString {
// 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.
// ErrorIs is a smuggler operator. It reports whether any error in an
// error's chain matches expectedError.
//
// _, err := os.Open("/unknown/file")
// td.Cmp(t, err, os.ErrNotExist) // fails
Expand All @@ -48,27 +48,62 @@ func errorToRawString(err error) types.RawString {
// td.Cmp(t, err, td.ErrorIs(err1)) // succeeds
// td.Cmp(t, err1, td.ErrorIs(err)) // fails
//
// Behind the scene it uses [errors.Is] function.
// var cerr myError
// td.Cmp(t, err, td.ErrorIs(td.Catch(&cerr, td.String("my error..."))))
//
// Note that like [errors.Is], expected can be nil: in this case the
// comparison succeeds when got is nil too.
// td.Cmp(t, err, td.ErrorIs(td.All(
// td.Isa(myError{}),
// td.String("my error..."),
// )))
//
// Behind the scene it uses [errors.Is] function if expectedError is
// an [error] and [errors.As] function if expectedError is a
// [TestDeep] operator.
//
// Note that like [errors.Is], expectedError can be nil: in this case
// the comparison succeeds only when got is nil too.
//
// See also [CmpError] and [CmpNoError].
func ErrorIs(expected error) TestDeep {
return &tdErrorIs{
baseOKNil: newBaseOKNil(3),
expected: expected,
func ErrorIs(expectedError any) TestDeep {
e := tdErrorIs{
tdSmugglerBase: newSmugglerBase(expectedError),
}

switch expErr := expectedError.(type) {
case nil:
case error:
e.expectedValue = reflect.ValueOf(expectedError)
case TestDeep:
e.typeBehind = expErr.TypeBehind()
if e.typeBehind == nil {
e.typeBehind = types.Interface
break
}
if !e.typeBehind.Implements(types.Error) &&
e.typeBehind.Kind() != reflect.Interface {
e.err = ctxerr.OpBad("ErrorIs",
"ErrorIs(%[1]s): type %[2]s behind %[1]s operator is not an interface or does not implement error",
expErr.GetLocation().Func, e.typeBehind)
}
default:
e.err = ctxerr.OpBadUsage("ErrorIs",
"(error|TESTDEEP_OPERATOR)", expectedError, 1, false)
}

return &e
}

func (e *tdErrorIs) Match(ctx ctxerr.Context, got reflect.Value) *ctxerr.Error {
if e.err != nil {
return ctx.CollectError(e.err)
}

// nil case
if !got.IsValid() {
// Special case
if e.expected == nil {
if !e.expectedValue.IsValid() {
return nil
}

if ctx.BooleanError {
return ctxerr.BooleanError
}
Expand Down Expand Up @@ -96,23 +131,63 @@ func (e *tdErrorIs) Match(ctx ctxerr.Context, got reflect.Value) *ctxerr.Error {
})
}

if errors.Is(gotErr, e.expected) {
return nil
if e.isTestDeeper {
target := reflect.New(e.typeBehind)

if !errors.As(gotErr, target.Interface()) {
if ctx.BooleanError {
return ctxerr.BooleanError
}
return ctx.CollectError(&ctxerr.Error{
Message: "type is not found in err's tree",
Got: gotIf,
Expected: types.RawString(e.typeBehind.String()),
})
}

return deepValueEqual(ctx.AddCustomLevel(S(".ErrorIs(%s)", e.typeBehind)),
target.Elem(), e.expectedValue)
}

var expErr error
if e.expectedValue.IsValid() {
expErr = e.expectedValue.Interface().(error)
if errors.Is(gotErr, expErr) {
return nil
}
if ctx.BooleanError {
return ctxerr.BooleanError
}
return ctx.CollectError(&ctxerr.Error{
Message: "is not found in err's tree",
Got: errorToRawString(gotErr),
Expected: errorToRawString(expErr),
})
}

if ctx.BooleanError {
return ctxerr.BooleanError
}
return ctx.CollectError(&ctxerr.Error{
Message: "is not the error",
Message: "is not nil",
Got: errorToRawString(gotErr),
Expected: errorToRawString(e.expected),
Expected: errorToRawString(expErr),
})
}

func (e *tdErrorIs) String() string {
if e.expected == nil {
if e.err != nil {
return e.stringError()
}
if e.isTestDeeper {
return "ErrorIs(" + e.expectedValue.Interface().(TestDeep).String() + ")"
}
if !e.expectedValue.IsValid() {
return "ErrorIs(nil)"
}
return "ErrorIs(" + e.expected.Error() + ")"
return "ErrorIs(" + e.expectedValue.Interface().(error).Error() + ")"
}

func (e *tdErrorIs) HandleInvalid() bool {
return true
}
110 changes: 99 additions & 11 deletions td/td_error_is_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,68 +8,153 @@ package td_test

import (
"fmt"
"io"
"testing"

"github.com/maxatome/go-testdeep/internal/dark"
"github.com/maxatome/go-testdeep/internal/test"
"github.com/maxatome/go-testdeep/td"
)

type errorIsSimpleErr string

func (e errorIsSimpleErr) Error() string {
return string(e)
}

type errorIsWrappedErr struct {
s string
err error
}

func (e errorIsWrappedErr) Error() string {
if e.err != nil {
return e.s + ": " + e.err.Error()
}
return e.s + ": nil"
}

func (e errorIsWrappedErr) Unwrap() error {
return e.err
}

var _ = []error{errorIsSimpleErr(""), errorIsWrappedErr{}}

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)
insideErr1 := errorIsSimpleErr("failure1")
insideErr2 := errorIsWrappedErr{"failure2", insideErr1}
insideErr3 := errorIsWrappedErr{"failure3", insideErr2}
err := errorIsWrappedErr{"failure4", 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))

checkOK(t, err, td.ErrorIs(td.All(
td.Isa(errorIsSimpleErr("")),
td.String("failure1"),
)))

// many errorIsWrappedErr in the err's tree, so only the first
// encountered matches
checkOK(t, err, td.ErrorIs(td.All(
td.Isa(errorIsWrappedErr{}),
td.HasPrefix("failure4"),
)))

// HasPrefix().TypeBehind() always returns nil
// so errors.As() is called with &any, so the toplevel error matches
checkOK(t, err, td.ErrorIs(td.HasPrefix("failure4")))

var errNil error
checkOK(t, &errNil, td.Ptr(td.ErrorIs(nil)))

var inside errorIsSimpleErr
checkOK(t, err, td.ErrorIs(td.Catch(&inside, td.String("failure1"))))
test.EqualStr(t, string(inside), "failure1")

checkError(t, nil, td.ErrorIs(insideErr1),
expectedError{
Message: mustBe("nil value"),
Path: mustBe("DATA"),
Message: mustBe("nil value"),
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"),
Message: mustBe("int does not implement error interface"),
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"),
Message: mustBe("int does not implement error interface"),
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"`),
Message: mustBe("is not found in err's tree"),
Got: mustBe(`(td_test.errorIsWrappedErr) "failure4: failure3: failure2: failure1"`),
Expected: mustBe(`(*errors.errorString) "another"`),
})

checkError(t, err, td.ErrorIs(td.String("nonono")),
expectedError{
Path: mustBe("DATA.ErrorIs(interface {})"),
Message: mustBe("does not match"),
Got: mustBe(`"failure4: failure3: failure2: failure1"`),
Expected: mustBe(`"nonono"`),
})

checkError(t, err, td.ErrorIs(td.Isa(fmt.Errorf("another"))),
expectedError{
Path: mustBe("DATA"),
Message: mustBe("type is not found in err's tree"),
Got: mustBe(`(td_test.errorIsWrappedErr) failure4: failure3: failure2: failure1`),
Expected: mustBe(`*errors.errorString`),
})

checkError(t, err, td.ErrorIs(td.Smuggle(io.ReadAll, td.String("xx"))),
expectedError{
Path: mustBe("DATA"),
Message: mustBe("type is not found in err's tree"),
Got: mustBe(`(td_test.errorIsWrappedErr) failure4: failure3: failure2: failure1`),
Expected: mustBe(`io.Reader`),
})

checkError(t, err, td.ErrorIs(nil),
expectedError{
Message: mustBe("is not the error"),
Path: mustBe("DATA"),
Got: mustBe(`(*fmt.wrapError) "failure4: failure3: failure2: failure1"`),
Message: mustBe("is not nil"),
Got: mustBe(`(td_test.errorIsWrappedErr) "failure4: failure3: failure2: failure1"`),
Expected: mustBe(`nil`),
})

// As errors.Is, it does not match
checkError(t, errorIsWrappedErr{"failure", nil}, td.ErrorIs(nil),
expectedError{
Path: mustBe("DATA"),
Message: mustBe("is not nil"),
Got: mustBe(`(td_test.errorIsWrappedErr) "failure: nil"`),
Expected: mustBe(`nil`),
})

checkError(t, err, td.ErrorIs(td.Gt(0)),
expectedError{
Path: mustBe("DATA"),
Message: mustBe("bad usage of ErrorIs operator"),
Summary: mustBe(`ErrorIs(Gt): type int behind Gt operator is not an interface or does not implement error`),
})

type private struct{ err error }
got := private{err: err}
for _, expErr := range []error{err, insideErr3} {
Expand All @@ -95,6 +180,9 @@ func TestErrorIs(t *testing.T) {
// String
test.EqualStr(t, td.ErrorIs(insideErr1).String(), "ErrorIs(failure1)")
test.EqualStr(t, td.ErrorIs(nil).String(), "ErrorIs(nil)")
test.EqualStr(t, td.ErrorIs(td.HasPrefix("pipo")).String(),
`ErrorIs(HasPrefix("pipo"))`)
test.EqualStr(t, td.ErrorIs(12).String(), "ErrorIs(<ERROR>)")
}

func TestErrorIsTypeBehind(t *testing.T) {
Expand Down