diff --git a/.golangci.yml b/.golangci.yml index 126d67a..fa62756 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -58,6 +58,13 @@ linters: - gosec # Tests don't need security stuff - goconst # Nah + # test.ErrorAs returns the matched error for optional chained assertions; + # discarding it is a supported pattern. errcheck's exclude-functions + # doesn't currently match generic instantiations, so match by source instead. + - source: 'test\.ErrorAs\[' + linters: + - errcheck + settings: cyclop: max-complexity: 20 diff --git a/README.md b/README.md index 6c60cce..d51a2ef 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,32 @@ func TestOutput(t *testing.T) { Under the hood `CaptureOutput` temporarily captures both streams, copies the data to a buffer and returns the output back to you, before cleaning everything back up again. +### A note on `ErrorAs` and `errcheck` + +`test.ErrorAs[T]` returns the matched error so you can chain further assertions on its fields: + +```go +got := test.ErrorAs[*os.PathError](t, err) +test.Equal(t, got.Op, "open") +``` + +The return value is optional — discarding it is a supported pattern when you only want the type-check assertion: + +```go +test.ErrorAs[*os.PathError](t, err) // pure type check, return ignored +``` + +If you lint with [`errcheck`] it will flag the discard because `T` is constrained to `error`. `errcheck`'s `exclude-functions` doesn't currently match generic instantiations, so the cleanest fix is a source-based exclusion in `.golangci.yml`: + +```yaml +linters: + exclusions: + rules: + - source: 'test\.ErrorAs\[' + linters: + - errcheck +``` + ### See Also - [FollowTheProcess/snapshot] for golden file/snapshot testing 📸 @@ -205,6 +231,7 @@ Under the hood `CaptureOutput` temporarily captures both streams, copies the dat This package was created with [copier] and the [FollowTheProcess/go_copier] project template. +[`errcheck`]: https://github.com/kisielk/errcheck [copier]: https://copier.readthedocs.io/en/stable/ [FollowTheProcess/go_copier]: https://github.com/FollowTheProcess/go_copier [matryer/is]: https://github.com/matryer/is diff --git a/test.go b/test.go index 7e3122d..1bfa34e 100644 --- a/test.go +++ b/test.go @@ -11,6 +11,7 @@ import ( "io" "math" "os" + "reflect" "strings" "testing" @@ -300,6 +301,85 @@ func Err(tb testing.TB, err error, options ...Option) { } } +// ErrorIs fails if err does not match target as reported by [errors.Is]. +// +// var ErrMadeUp = errors.New("made up error") +// test.ErrorIs(t, err, ErrMadeUp) +func ErrorIs(tb testing.TB, err, target error, options ...Option) { + tb.Helper() + + cfg := defaultConfig() + cfg.title = "Wrong Error" + + for _, option := range options { + if optionErr := option.apply(&cfg); optionErr != nil { + tb.Fatalf("ErrorIs: could not apply options: %v", optionErr) + + return + } + } + + if !errors.Is(err, target) { + fail := failure[error]{ + got: err, + want: target, + cfg: cfg, + } + tb.Fatal(fail.String()) + } +} + +// ErrorAs asserts that err or some error in its chain matches the concrete +// type T as reported by [errors.AsType], and returns the matched error so +// the caller can make further assertions on its fields without having to +// unwrap it a second time. The return value may be ignored. +// +// Discard the return when you only care about the type check: +// +// test.ErrorAs[*os.PathError](t, err) +// +// Or bind it to drill into the matched error: +// +// got := test.ErrorAs[*os.PathError](t, err) +// test.Equal(t, got.Op, "open") +// test.Equal(t, got.Path, "/does/not/exist") +func ErrorAs[T error](tb testing.TB, err error, options ...Option) T { + tb.Helper() + + cfg := defaultConfig() + cfg.title = "Wrong Error Type" + + for _, option := range options { + if optionErr := option.apply(&cfg); optionErr != nil { + tb.Fatalf("ErrorAs: could not apply options: %v", optionErr) + + var zero T + + return zero + } + } + + if target, ok := errors.AsType[T](err); ok { + return target + } + + got := "" + if err != nil { + got = fmt.Sprintf("%T: %s", err, err.Error()) + } + + fail := failure[string]{ + got: got, + want: fmt.Sprintf("error matching %s", reflect.TypeFor[T]()), + cfg: cfg, + } + tb.Fatal(fail.String()) + + var zero T + + return zero +} + // WantErr fails if you got an error and didn't want it, or if you didn't // get an error but wanted one. // diff --git a/test_test.go b/test_test.go index cbd4cf2..0076d26 100644 --- a/test_test.go +++ b/test_test.go @@ -325,6 +325,104 @@ func TestTest(t *testing.T) { }, wantFail: true, }, + { + name: "ErrorIs/pass", + fn: func(tb testing.TB) { + sentinel := errors.New("sentinel") + test.ErrorIs(tb, sentinel, sentinel) + }, + wantFail: false, + }, + { + name: "ErrorIs/pass wrapped", + fn: func(tb testing.TB) { + sentinel := errors.New("sentinel") + wrapped := fmt.Errorf("while frobnicating: %w", sentinel) + test.ErrorIs(tb, wrapped, sentinel) + }, + wantFail: false, + }, + { + name: "ErrorIs/fail", + fn: func(tb testing.TB) { + test.ErrorIs(tb, errors.New("bang"), errors.New("not bang")) + }, + wantFail: true, + }, + { + name: "ErrorIs/fail nil", + fn: func(tb testing.TB) { + test.ErrorIs(tb, nil, errors.New("wanted this one")) + }, + wantFail: true, + }, + { + name: "ErrorIs/fail with context", + fn: func(tb testing.TB) { + test.ErrorIs(tb, errors.New("bang"), errors.New("not bang"), test.Context("Expected the other error")) + }, + wantFail: true, + }, + { + name: "ErrorIs/fail with title", + fn: func(tb testing.TB) { + test.ErrorIs(tb, errors.New("bang"), errors.New("not bang"), test.Title("Wrong one")) + }, + wantFail: true, + }, + { + name: "ErrorAs/pass", + fn: func(tb testing.TB) { + boom := &inputError{msg: "boom"} + + got := test.ErrorAs[*inputError](tb, boom) + if got != boom { + tb.Fatal("ErrorAs did not return the matched error") + } + }, + wantFail: false, + }, + { + name: "ErrorAs/pass wrapped", + fn: func(tb testing.TB) { + inner := &inputError{msg: "boom"} + wrapped := fmt.Errorf("while frobnicating: %w", inner) + + got := test.ErrorAs[*inputError](tb, wrapped) + if got != inner { + tb.Fatal("ErrorAs did not return the wrapped error") + } + }, + wantFail: false, + }, + { + name: "ErrorAs/fail", + fn: func(tb testing.TB) { + test.ErrorAs[*inputError](tb, &outputError{msg: "nope"}) + }, + wantFail: true, + }, + { + name: "ErrorAs/fail nil", + fn: func(tb testing.TB) { + test.ErrorAs[*inputError](tb, nil) + }, + wantFail: true, + }, + { + name: "ErrorAs/fail with context", + fn: func(tb testing.TB) { + test.ErrorAs[*inputError](tb, &outputError{msg: "nope"}, test.Context("Expected an inputError")) + }, + wantFail: true, + }, + { + name: "ErrorAs/fail with title", + fn: func(tb testing.TB) { + test.ErrorAs[*inputError](tb, &outputError{msg: "nope"}, test.Title("Type mismatch")) + }, + wantFail: true, + }, { name: "WantErr/pass error", fn: func(tb testing.TB) { @@ -621,3 +719,14 @@ func TestCapture(t *testing.T) { test.Equal(t, stderr, "") }) } + +// inputError is a concrete error type used to exercise test.ErrorAs. +type inputError struct{ msg string } + +func (e *inputError) Error() string { return e.msg } + +// outputError is an unrelated concrete error type used to exercise the +// "wrong type" failure path of test.ErrorAs. +type outputError struct{ msg string } + +func (e *outputError) Error() string { return e.msg } diff --git a/testdata/snapshots/TestTest/ErrorAs/fail.snap b/testdata/snapshots/TestTest/ErrorAs/fail.snap new file mode 100644 index 0000000..54ece5e --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorAs/fail.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error Type + ---------------- + + Got: *test_test.outputError: nope + Wanted: error matching *test_test.inputError diff --git a/testdata/snapshots/TestTest/ErrorAs/fail_nil.snap b/testdata/snapshots/TestTest/ErrorAs/fail_nil.snap new file mode 100644 index 0000000..a59e390 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorAs/fail_nil.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error Type + ---------------- + + Got: + Wanted: error matching *test_test.inputError diff --git a/testdata/snapshots/TestTest/ErrorAs/fail_with_context.snap b/testdata/snapshots/TestTest/ErrorAs/fail_with_context.snap new file mode 100644 index 0000000..5c01033 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorAs/fail_with_context.snap @@ -0,0 +1,12 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error Type + ---------------- + + Got: *test_test.outputError: nope + Wanted: error matching *test_test.inputError + + (Expected an inputError) diff --git a/testdata/snapshots/TestTest/ErrorAs/fail_with_title.snap b/testdata/snapshots/TestTest/ErrorAs/fail_with_title.snap new file mode 100644 index 0000000..f314bf0 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorAs/fail_with_title.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Type mismatch + ------------- + + Got: *test_test.outputError: nope + Wanted: error matching *test_test.inputError diff --git a/testdata/snapshots/TestTest/ErrorIs/fail.snap b/testdata/snapshots/TestTest/ErrorIs/fail.snap new file mode 100644 index 0000000..495c9b8 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorIs/fail.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error + ----------- + + Got: bang + Wanted: not bang diff --git a/testdata/snapshots/TestTest/ErrorIs/fail_nil.snap b/testdata/snapshots/TestTest/ErrorIs/fail_nil.snap new file mode 100644 index 0000000..d132719 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorIs/fail_nil.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error + ----------- + + Got: + Wanted: wanted this one diff --git a/testdata/snapshots/TestTest/ErrorIs/fail_with_context.snap b/testdata/snapshots/TestTest/ErrorIs/fail_with_context.snap new file mode 100644 index 0000000..52b63b2 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorIs/fail_with_context.snap @@ -0,0 +1,12 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong Error + ----------- + + Got: bang + Wanted: not bang + + (Expected the other error) diff --git a/testdata/snapshots/TestTest/ErrorIs/fail_with_title.snap b/testdata/snapshots/TestTest/ErrorIs/fail_with_title.snap new file mode 100644 index 0000000..8d6d159 --- /dev/null +++ b/testdata/snapshots/TestTest/ErrorIs/fail_with_title.snap @@ -0,0 +1,10 @@ +source: test_test.go +expression: buf.String() +--- +| + + Wrong one + --------- + + Got: bang + Wanted: not bang