Repository: stretchr/testify (go)

Issue:
assert.ErrorAs: confusing error message
Typically, the target passed to `errors.As` has a zero value. But if the `errors.As` call is wrapped by `assert.ErrorAs`, this can lead to a confusing error message if the assertion fails:

```
--- FAIL: TestCustomErr (0.00s)
    main_test.go:32: 
                Error Trace:    /.../main_test.go:32
                Error:          Should be in error chain:
                                expected: %!q(PANIC=Error method: runtime error: invalid memory address or nil pointer dereference)
                                in chain: "file does not exist"
                Test:           TestCustomErr
                Messages:       index 1
FAIL
```

For `expected:`, it's printing the target. But in my case the target can't be printed until it's assigned a value, and I think printing the target is not useful anyway - I think what it should be doing instead is printing the type of the target:

```
--- FAIL: TestCustomErr (0.00s)
    main_test.go:32: 
                Error Trace:    /.../main_test.go:32
                Error:          Should be in error chain:
                                expected: "*main.customErr"
                                in chain: "file does not exist"
                Test:           TestCustomErr
                Messages:       index 1
FAIL
```

Here's the code I used:
```go
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"testing"

	"github.com/stretchr/testify/assert"
)

type customErr struct {
	err error
}

func (ce customErr) Error() string {
	return ce.err.Error()
}

func getErrs() []error {
	return []error{
		customErr{
			err: errors.New(`something`),
		},
		fs.ErrNotExist,
	}
}

func TestCustomErr(t *testing.T) {
	for i, err := range getErrs() {
		var aCustomErr customErr
		assert.ErrorAs(t, err, &aCustomErr, fmt.Sprintf(`index %v`, i))
	}
}
```

I am happy to submit a PR to address this.


Pull request:
assert.ErrorAs: log target type
## Summary
When an assertion fails in assert.ErrorAs, log the target error type rather than its value.

## Changes
Passes the target error type, rather than the target itself, to the Fail() call in assert.ErrorAs. Also, modifies buildErrorChainString to support go 1.20 error wrapping, and to include the error type in its output when called by assert.ErrorAs.

## Motivation
The existing error message was confusing - see #1343 

<!-- ## Example usage (if applicable) -->

## Related issues
Closes #1343


Patch:
diff --git a/assert/assertions.go b/assert/assertions.go
index 4e91332..be82161 100644
--- a/assert/assertions.go
+++ b/assert/assertions.go
@@ -2102,7 +2102,7 @@ func ErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool {
 		expectedText = target.Error()
 	}
 
-	chain := buildErrorChainString(err)
+	chain := buildErrorChainString(err, false)
 
 	return Fail(t, fmt.Sprintf("Target error should be in err chain:\n"+
 		"expected: %q\n"+
@@ -2125,7 +2125,7 @@ func NotErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool {
 		expectedText = target.Error()
 	}
 
-	chain := buildErrorChainString(err)
+	chain := buildErrorChainString(err, false)
 
 	return Fail(t, fmt.Sprintf("Target error should not be in err chain:\n"+
 		"found: %q\n"+
@@ -2143,11 +2143,11 @@ func ErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interface{
 		return true
 	}
 
-	chain := buildErrorChainString(err)
+	chain := buildErrorChainString(err, true)
 
 	return Fail(t, fmt.Sprintf("Should be in error chain:\n"+
-		"expected: %q\n"+
-		"in chain: %s", target, chain,
+		"expected: %s\n"+
+		"in chain: %s", reflect.ValueOf(target).Elem().Type(), chain,
 	), msgAndArgs...)
 }
 
@@ -2161,24 +2161,46 @@ func NotErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interfa
 		return true
 	}
 
-	chain := buildErrorChainString(err)
+	chain := buildErrorChainString(err, true)
 
 	return Fail(t, fmt.Sprintf("Target error should not be in err chain:\n"+
-		"found: %q\n"+
-		"in chain: %s", target, chain,
+		"found: %s\n"+
+		"in chain: %s", reflect.ValueOf(target).Elem().Type(), chain,
 	), msgAndArgs...)
 }
 
-func buildErrorChainString(err error) string {
+func unwrapAll(err error) (errs []error) {
+	errs = append(errs, err)
+	switch x := err.(type) {
+	case interface{ Unwrap() error }:
+		err = x.Unwrap()
+		if err == nil {
+			return
+		}
+		errs = append(errs, unwrapAll(err)...)
+	case interface{ Unwrap() []error }:
+		for _, err := range x.Unwrap() {
+			errs = append(errs, unwrapAll(err)...)
+		}
+	}
+	return
+}
+
+func buildErrorChainString(err error, withType bool) string {
 	if err == nil {
 		return ""
 	}
 
-	e := errors.Unwrap(err)
-	chain := fmt.Sprintf("%q", err.Error())
-	for e != nil {
-		chain += fmt.Sprintf("\n\t%q", e.Error())
-		e = errors.Unwrap(e)
+	var chain string
+	errs := unwrapAll(err)
+	for i := range errs {
+		if i != 0 {
+			chain += "\n\t"
+		}
+		chain += fmt.Sprintf("%q", errs[i].Error())
+		if withType {
+			chain += fmt.Sprintf(" (%T)", errs[i])
+		}
 	}
 	return chain
 }


Test patch:
diff --git a/assert/assertions_test.go b/assert/assertions_test.go
index 9f859fa..503eadb 100644
--- a/assert/assertions_test.go
+++ b/assert/assertions_test.go
@@ -3175,11 +3175,13 @@ func parseLabeledOutput(output string) []labeledContent {
 }
 
 type captureTestingT struct {
-	msg string
+	failed bool
+	msg    string
 }
 
 func (ctt *captureTestingT) Errorf(format string, args ...interface{}) {
 	ctt.msg = fmt.Sprintf(format, args...)
+	ctt.failed = true
 }
 
 func (ctt *captureTestingT) checkResultAndErrMsg(t *testing.T, expectedRes, res bool, expectedErrMsg string) {
@@ -3188,6 +3190,10 @@ func (ctt *captureTestingT) checkResultAndErrMsg(t *testing.T, expectedRes, res
 		t.Errorf("Should return %t", expectedRes)
 		return
 	}
+	if res == ctt.failed {
+		t.Errorf("The test result (%t) should be reflected in the testing.T type (%t)", res, !ctt.failed)
+		return
+	}
 	contents := parseLabeledOutput(ctt.msg)
 	if res == true {
 		if contents != nil {
@@ -3348,50 +3354,82 @@ func TestNotErrorIs(t *testing.T) {
 
 func TestErrorAs(t *testing.T) {
 	tests := []struct {
-		err    error
-		result bool
+		err          error
+		result       bool
+		resultErrMsg string
 	}{
-		{fmt.Errorf("wrap: %w", &customError{}), true},
-		{io.EOF, false},
-		{nil, false},
+		{
+			err:    fmt.Errorf("wrap: %w", &customError{}),
+			result: true,
+		},
+		{
+			err:    io.EOF,
+			result: false,
+			resultErrMsg: "" +
+				"Should be in error chain:\n" +
+				"expected: *assert.customError\n" +
+				"in chain: \"EOF\" (*errors.errorString)\n",
+		},
+		{
+			err:    nil,
+			result: false,
+			resultErrMsg: "" +
+				"Should be in error chain:\n" +
+				"expected: *assert.customError\n" +
+				"in chain: \n",
+		},
+		{
+			err:    fmt.Errorf("abc: %w", errors.New("def")),
+			result: false,
+			resultErrMsg: "" +
+				"Should be in error chain:\n" +
+				"expected: *assert.customError\n" +
+				"in chain: \"abc: def\" (*fmt.wrapError)\n" +
+				"\t\"def\" (*errors.errorString)\n",
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
 		var target *customError
 		t.Run(fmt.Sprintf("ErrorAs(%#v,%#v)", tt.err, target), func(t *testing.T) {
-			mockT := new(testing.T)
+			mockT := new(captureTestingT)
 			res := ErrorAs(mockT, tt.err, &target)
-			if res != tt.result {
-				t.Errorf("ErrorAs(%#v,%#v) should return %t", tt.err, target, tt.result)
-			}
-			if res == mockT.Failed() {
-				t.Errorf("The test result (%t) should be reflected in the testing.T type (%t)", res, !mockT.Failed())
-			}
+			mockT.checkResultAndErrMsg(t, tt.result, res, tt.resultErrMsg)
 		})
 	}
 }
 
 func TestNotErrorAs(t *testing.T) {
 	tests := []struct {
-		err    error
-		result bool
+		err          error
+		result       bool
+		resultErrMsg string
 	}{
-		{fmt.Errorf("wrap: %w", &customError{}), false},
-		{io.EOF, true},
-		{nil, true},
+		{
+			err:    fmt.Errorf("wrap: %w", &customError{}),
+			result: false,
+			resultErrMsg: "" +
+				"Target error should not be in err chain:\n" +
+				"found: *assert.customError\n" +
+				"in chain: \"wrap: fail\" (*fmt.wrapError)\n" +
+				"\t\"fail\" (*assert.customError)\n",
+		},
+		{
+			err:    io.EOF,
+			result: true,
+		},
+		{
+			err:    nil,
+			result: true,
+		},
 	}
 	for _, tt := range tests {
 		tt := tt
 		var target *customError
 		t.Run(fmt.Sprintf("NotErrorAs(%#v,%#v)", tt.err, target), func(t *testing.T) {
-			mockT := new(testing.T)
+			mockT := new(captureTestingT)
 			res := NotErrorAs(mockT, tt.err, &target)
-			if res != tt.result {
-				t.Errorf("NotErrorAs(%#v,%#v) should not return %t", tt.err, target, tt.result)
-			}
-			if res == mockT.Failed() {
-				t.Errorf("The test result (%t) should be reflected in the testing.T type (%t)", res, !mockT.Failed())
-			}
+			mockT.checkResultAndErrMsg(t, tt.result, res, tt.resultErrMsg)
 		})
 	}
 }