Skip to content
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
7 changes: 7 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 📸
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"math"
"os"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -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 := "<nil>"
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.
//
Expand Down
109 changes: 109 additions & 0 deletions test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorAs/fail.snap
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorAs/fail_nil.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
source: test_test.go
expression: buf.String()
---
|

Wrong Error Type
----------------

Got: <nil>
Wanted: error matching *test_test.inputError
12 changes: 12 additions & 0 deletions testdata/snapshots/TestTest/ErrorAs/fail_with_context.snap
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorAs/fail_with_title.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
source: test_test.go
expression: buf.String()
---
|

Type mismatch
-------------

Got: *test_test.outputError: nope
Wanted: error matching *test_test.inputError
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorIs/fail.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
source: test_test.go
expression: buf.String()
---
|

Wrong Error
-----------

Got: bang
Wanted: not bang
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorIs/fail_nil.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
source: test_test.go
expression: buf.String()
---
|

Wrong Error
-----------

Got: <nil>
Wanted: wanted this one
12 changes: 12 additions & 0 deletions testdata/snapshots/TestTest/ErrorIs/fail_with_context.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
source: test_test.go
expression: buf.String()
---
|

Wrong Error
-----------

Got: bang
Wanted: not bang

(Expected the other error)
10 changes: 10 additions & 0 deletions testdata/snapshots/TestTest/ErrorIs/fail_with_title.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
source: test_test.go
expression: buf.String()
---
|

Wrong one
---------

Got: bang
Wanted: not bang
Loading