Skip to content

Commit 5a163cb

Browse files
Add ErrorIs and ErrorAs (#105)
1 parent 4d09b51 commit 5a163cb

12 files changed

Lines changed: 307 additions & 0 deletions

File tree

.golangci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ linters:
5858
- gosec # Tests don't need security stuff
5959
- goconst # Nah
6060

61+
# test.ErrorAs returns the matched error for optional chained assertions;
62+
# discarding it is a supported pattern. errcheck's exclude-functions
63+
# doesn't currently match generic instantiations, so match by source instead.
64+
- source: 'test\.ErrorAs\['
65+
linters:
66+
- errcheck
67+
6168
settings:
6269
cyclop:
6370
max-complexity: 20

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,32 @@ func TestOutput(t *testing.T) {
197197

198198
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.
199199

200+
### A note on `ErrorAs` and `errcheck`
201+
202+
`test.ErrorAs[T]` returns the matched error so you can chain further assertions on its fields:
203+
204+
```go
205+
got := test.ErrorAs[*os.PathError](t, err)
206+
test.Equal(t, got.Op, "open")
207+
```
208+
209+
The return value is optional — discarding it is a supported pattern when you only want the type-check assertion:
210+
211+
```go
212+
test.ErrorAs[*os.PathError](t, err) // pure type check, return ignored
213+
```
214+
215+
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`:
216+
217+
```yaml
218+
linters:
219+
exclusions:
220+
rules:
221+
- source: 'test\.ErrorAs\['
222+
linters:
223+
- errcheck
224+
```
225+
200226
### See Also
201227
202228
- [FollowTheProcess/snapshot] for golden file/snapshot testing 📸
@@ -205,6 +231,7 @@ Under the hood `CaptureOutput` temporarily captures both streams, copies the dat
205231
206232
This package was created with [copier] and the [FollowTheProcess/go_copier] project template.
207233
234+
[`errcheck`]: https://github.com/kisielk/errcheck
208235
[copier]: https://copier.readthedocs.io/en/stable/
209236
[FollowTheProcess/go_copier]: https://github.com/FollowTheProcess/go_copier
210237
[matryer/is]: https://github.com/matryer/is

test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"io"
1212
"math"
1313
"os"
14+
"reflect"
1415
"strings"
1516
"testing"
1617

@@ -300,6 +301,85 @@ func Err(tb testing.TB, err error, options ...Option) {
300301
}
301302
}
302303

304+
// ErrorIs fails if err does not match target as reported by [errors.Is].
305+
//
306+
// var ErrMadeUp = errors.New("made up error")
307+
// test.ErrorIs(t, err, ErrMadeUp)
308+
func ErrorIs(tb testing.TB, err, target error, options ...Option) {
309+
tb.Helper()
310+
311+
cfg := defaultConfig()
312+
cfg.title = "Wrong Error"
313+
314+
for _, option := range options {
315+
if optionErr := option.apply(&cfg); optionErr != nil {
316+
tb.Fatalf("ErrorIs: could not apply options: %v", optionErr)
317+
318+
return
319+
}
320+
}
321+
322+
if !errors.Is(err, target) {
323+
fail := failure[error]{
324+
got: err,
325+
want: target,
326+
cfg: cfg,
327+
}
328+
tb.Fatal(fail.String())
329+
}
330+
}
331+
332+
// ErrorAs asserts that err or some error in its chain matches the concrete
333+
// type T as reported by [errors.AsType], and returns the matched error so
334+
// the caller can make further assertions on its fields without having to
335+
// unwrap it a second time. The return value may be ignored.
336+
//
337+
// Discard the return when you only care about the type check:
338+
//
339+
// test.ErrorAs[*os.PathError](t, err)
340+
//
341+
// Or bind it to drill into the matched error:
342+
//
343+
// got := test.ErrorAs[*os.PathError](t, err)
344+
// test.Equal(t, got.Op, "open")
345+
// test.Equal(t, got.Path, "/does/not/exist")
346+
func ErrorAs[T error](tb testing.TB, err error, options ...Option) T {
347+
tb.Helper()
348+
349+
cfg := defaultConfig()
350+
cfg.title = "Wrong Error Type"
351+
352+
for _, option := range options {
353+
if optionErr := option.apply(&cfg); optionErr != nil {
354+
tb.Fatalf("ErrorAs: could not apply options: %v", optionErr)
355+
356+
var zero T
357+
358+
return zero
359+
}
360+
}
361+
362+
if target, ok := errors.AsType[T](err); ok {
363+
return target
364+
}
365+
366+
got := "<nil>"
367+
if err != nil {
368+
got = fmt.Sprintf("%T: %s", err, err.Error())
369+
}
370+
371+
fail := failure[string]{
372+
got: got,
373+
want: fmt.Sprintf("error matching %s", reflect.TypeFor[T]()),
374+
cfg: cfg,
375+
}
376+
tb.Fatal(fail.String())
377+
378+
var zero T
379+
380+
return zero
381+
}
382+
303383
// WantErr fails if you got an error and didn't want it, or if you didn't
304384
// get an error but wanted one.
305385
//

test_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,104 @@ func TestTest(t *testing.T) {
325325
},
326326
wantFail: true,
327327
},
328+
{
329+
name: "ErrorIs/pass",
330+
fn: func(tb testing.TB) {
331+
sentinel := errors.New("sentinel")
332+
test.ErrorIs(tb, sentinel, sentinel)
333+
},
334+
wantFail: false,
335+
},
336+
{
337+
name: "ErrorIs/pass wrapped",
338+
fn: func(tb testing.TB) {
339+
sentinel := errors.New("sentinel")
340+
wrapped := fmt.Errorf("while frobnicating: %w", sentinel)
341+
test.ErrorIs(tb, wrapped, sentinel)
342+
},
343+
wantFail: false,
344+
},
345+
{
346+
name: "ErrorIs/fail",
347+
fn: func(tb testing.TB) {
348+
test.ErrorIs(tb, errors.New("bang"), errors.New("not bang"))
349+
},
350+
wantFail: true,
351+
},
352+
{
353+
name: "ErrorIs/fail nil",
354+
fn: func(tb testing.TB) {
355+
test.ErrorIs(tb, nil, errors.New("wanted this one"))
356+
},
357+
wantFail: true,
358+
},
359+
{
360+
name: "ErrorIs/fail with context",
361+
fn: func(tb testing.TB) {
362+
test.ErrorIs(tb, errors.New("bang"), errors.New("not bang"), test.Context("Expected the other error"))
363+
},
364+
wantFail: true,
365+
},
366+
{
367+
name: "ErrorIs/fail with title",
368+
fn: func(tb testing.TB) {
369+
test.ErrorIs(tb, errors.New("bang"), errors.New("not bang"), test.Title("Wrong one"))
370+
},
371+
wantFail: true,
372+
},
373+
{
374+
name: "ErrorAs/pass",
375+
fn: func(tb testing.TB) {
376+
boom := &inputError{msg: "boom"}
377+
378+
got := test.ErrorAs[*inputError](tb, boom)
379+
if got != boom {
380+
tb.Fatal("ErrorAs did not return the matched error")
381+
}
382+
},
383+
wantFail: false,
384+
},
385+
{
386+
name: "ErrorAs/pass wrapped",
387+
fn: func(tb testing.TB) {
388+
inner := &inputError{msg: "boom"}
389+
wrapped := fmt.Errorf("while frobnicating: %w", inner)
390+
391+
got := test.ErrorAs[*inputError](tb, wrapped)
392+
if got != inner {
393+
tb.Fatal("ErrorAs did not return the wrapped error")
394+
}
395+
},
396+
wantFail: false,
397+
},
398+
{
399+
name: "ErrorAs/fail",
400+
fn: func(tb testing.TB) {
401+
test.ErrorAs[*inputError](tb, &outputError{msg: "nope"})
402+
},
403+
wantFail: true,
404+
},
405+
{
406+
name: "ErrorAs/fail nil",
407+
fn: func(tb testing.TB) {
408+
test.ErrorAs[*inputError](tb, nil)
409+
},
410+
wantFail: true,
411+
},
412+
{
413+
name: "ErrorAs/fail with context",
414+
fn: func(tb testing.TB) {
415+
test.ErrorAs[*inputError](tb, &outputError{msg: "nope"}, test.Context("Expected an inputError"))
416+
},
417+
wantFail: true,
418+
},
419+
{
420+
name: "ErrorAs/fail with title",
421+
fn: func(tb testing.TB) {
422+
test.ErrorAs[*inputError](tb, &outputError{msg: "nope"}, test.Title("Type mismatch"))
423+
},
424+
wantFail: true,
425+
},
328426
{
329427
name: "WantErr/pass error",
330428
fn: func(tb testing.TB) {
@@ -621,3 +719,14 @@ func TestCapture(t *testing.T) {
621719
test.Equal(t, stderr, "")
622720
})
623721
}
722+
723+
// inputError is a concrete error type used to exercise test.ErrorAs.
724+
type inputError struct{ msg string }
725+
726+
func (e *inputError) Error() string { return e.msg }
727+
728+
// outputError is an unrelated concrete error type used to exercise the
729+
// "wrong type" failure path of test.ErrorAs.
730+
type outputError struct{ msg string }
731+
732+
func (e *outputError) Error() string { return e.msg }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Wrong Error Type
7+
----------------
8+
9+
Got: *test_test.outputError: nope
10+
Wanted: error matching *test_test.inputError
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Wrong Error Type
7+
----------------
8+
9+
Got: <nil>
10+
Wanted: error matching *test_test.inputError
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Wrong Error Type
7+
----------------
8+
9+
Got: *test_test.outputError: nope
10+
Wanted: error matching *test_test.inputError
11+
12+
(Expected an inputError)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Type mismatch
7+
-------------
8+
9+
Got: *test_test.outputError: nope
10+
Wanted: error matching *test_test.inputError
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Wrong Error
7+
-----------
8+
9+
Got: bang
10+
Wanted: not bang
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
source: test_test.go
2+
expression: buf.String()
3+
---
4+
|
5+
6+
Wrong Error
7+
-----------
8+
9+
Got: <nil>
10+
Wanted: wanted this one

0 commit comments

Comments
 (0)