From 97f2a7402e4927f8dbaebf8b16dfcaa796ad31b3 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Thu, 12 Mar 2026 17:36:51 -0700 Subject: [PATCH 1/3] add ErrorAsType and related functions --- assert/assertions_go1.26.go | 79 ++++++++++++++++++++++++ assert/assertions_go1.26_test.go | 102 +++++++++++++++++++++++++++++++ require/require_go1.26.go | 69 +++++++++++++++++++++ require/require_go1.26_test.go | 52 ++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 assert/assertions_go1.26.go create mode 100644 assert/assertions_go1.26_test.go create mode 100644 require/require_go1.26.go create mode 100644 require/require_go1.26_test.go diff --git a/assert/assertions_go1.26.go b/assert/assertions_go1.26.go new file mode 100644 index 000000000..09bae1323 --- /dev/null +++ b/assert/assertions_go1.26.go @@ -0,0 +1,79 @@ +//go:build go1.26 + +package assert + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrorAsType asserts that at least one of the errors in err's tree matches +// type E, using errors.AsType. On success it returns the matched error value. +// This is a Go 1.26+ generic alternative to ErrorAs that avoids the need for +// a pre-declared target variable. +// +// assert.ErrorAsType[*json.SyntaxError](t, err) +func ErrorAsType[E error](t TestingT, err error, msgAndArgs ...any) (E, bool) { + if h, ok := t.(tHelper); ok { + h.Helper() + } + + if target, ok := errors.AsType[E](err); ok { + return target, true + } + + expectedType := reflect.TypeFor[E]().String() + if err == nil { + Fail(t, fmt.Sprintf("An error is expected but got nil.\n"+ + "expected: %s", expectedType), msgAndArgs...) + var zero E + return zero, false + } + + chain := buildErrorChainString(err, true) + Fail(t, fmt.Sprintf("Should be in error chain:\n"+ + "expected: %s\n"+ + "in chain: %s", expectedType, truncatingFormat("%s", chain), + ), msgAndArgs...) + var zero E + return zero, false +} + +// ErrorAsTypef asserts that at least one of the errors in err's tree matches +// type E, using errors.AsType. On success it returns the matched error value. +// This is a Go 1.26+ generic alternative to ErrorAs that avoids the need for +// a pre-declared target variable. +func ErrorAsTypef[E error](t TestingT, err error, msg string, args ...any) (E, bool) { + if h, ok := t.(tHelper); ok { + h.Helper() + } + return ErrorAsType[E](t, err, append([]any{msg}, args...)...) +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +// This is a Go 1.26+ generic alternative to NotErrorAs. +func NotErrorAsType[E error](t TestingT, err error, msgAndArgs ...any) bool { + if h, ok := t.(tHelper); ok { + h.Helper() + } + + if _, ok := errors.AsType[E](err); !ok { + return true + } + + chain := buildErrorChainString(err, true) + return Fail(t, fmt.Sprintf("Target error should not be in err chain:\n"+ + "found: %s\n"+ + "in chain: %s", reflect.TypeFor[E]().String(), truncatingFormat("%s", chain), + ), msgAndArgs...) +} + +// NotErrorAsTypef asserts that no error in err's tree matches type E. +// This is a Go 1.26+ generic alternative to NotErrorAs. +func NotErrorAsTypef[E error](t TestingT, err error, msg string, args ...any) bool { + if h, ok := t.(tHelper); ok { + h.Helper() + } + return NotErrorAsType[E](t, err, append([]any{msg}, args...)...) +} diff --git a/assert/assertions_go1.26_test.go b/assert/assertions_go1.26_test.go new file mode 100644 index 000000000..6b49719b3 --- /dev/null +++ b/assert/assertions_go1.26_test.go @@ -0,0 +1,102 @@ +//go:build go1.26 + +package assert + +import ( + "errors" + "fmt" + "io" + "testing" +) + +func TestErrorAsType(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + result bool + resultErrMsg string + }{ + { + 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: "" + + "An error is expected but got nil.\n" + + "expected: *assert.customError\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 { + t.Run(fmt.Sprintf("ErrorAsType[*customError](%#v)", tt.err), func(t *testing.T) { + mockT := new(captureTestingT) + target, ok := ErrorAsType[*customError](mockT, tt.err) + if tt.result { + if !ok { + t.Error("expected ok=true but got false") + } + if target == nil { + t.Error("expected non-nil target on success") + } + } else { + mockT.checkResultAndErrMsg(t, false, ok, tt.resultErrMsg) + } + }) + } +} + +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + result bool + resultErrMsg string + }{ + { + 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 { + t.Run(fmt.Sprintf("NotErrorAsType[*customError](%#v)", tt.err), func(t *testing.T) { + mockT := new(captureTestingT) + res := NotErrorAsType[*customError](mockT, tt.err) + mockT.checkResultAndErrMsg(t, tt.result, res, tt.resultErrMsg) + }) + } +} diff --git a/require/require_go1.26.go b/require/require_go1.26.go new file mode 100644 index 000000000..37ddd37f8 --- /dev/null +++ b/require/require_go1.26.go @@ -0,0 +1,69 @@ +//go:build go1.26 + +package require + +import ( + assert "github.com/stretchr/testify/assert" +) + +// ErrorAsType asserts that at least one of the errors in err's tree matches +// type E, using errors.AsType. On success it returns the matched error value. +// This is a Go 1.26+ generic alternative to ErrorAs that avoids the need for +// a pre-declared target variable. +// +// If the assertion fails, FailNow is called. +func ErrorAsType[E error](t TestingT, err error, msgAndArgs ...any) E { + if h, ok := t.(tHelper); ok { + h.Helper() + } + target, ok := assert.ErrorAsType[E](t, err, msgAndArgs...) + if !ok { + t.FailNow() + } + return target +} + +// ErrorAsTypef asserts that at least one of the errors in err's tree matches +// type E, using errors.AsType. On success it returns the matched error value. +// This is a Go 1.26+ generic alternative to ErrorAs that avoids the need for +// a pre-declared target variable. +// +// If the assertion fails, FailNow is called. +func ErrorAsTypef[E error](t TestingT, err error, msg string, args ...any) E { + if h, ok := t.(tHelper); ok { + h.Helper() + } + target, ok := assert.ErrorAsTypef[E](t, err, msg, args...) + if !ok { + t.FailNow() + } + return target +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +// This is a Go 1.26+ generic alternative to NotErrorAs. +// +// If the assertion fails, FailNow is called. +func NotErrorAsType[E error](t TestingT, err error, msgAndArgs ...any) { + if h, ok := t.(tHelper); ok { + h.Helper() + } + if assert.NotErrorAsType[E](t, err, msgAndArgs...) { + return + } + t.FailNow() +} + +// NotErrorAsTypef asserts that no error in err's tree matches type E. +// This is a Go 1.26+ generic alternative to NotErrorAs. +// +// If the assertion fails, FailNow is called. +func NotErrorAsTypef[E error](t TestingT, err error, msg string, args ...any) { + if h, ok := t.(tHelper); ok { + h.Helper() + } + if assert.NotErrorAsTypef[E](t, err, msg, args...) { + return + } + t.FailNow() +} diff --git a/require/require_go1.26_test.go b/require/require_go1.26_test.go new file mode 100644 index 000000000..54f620464 --- /dev/null +++ b/require/require_go1.26_test.go @@ -0,0 +1,52 @@ +//go:build go1.26 + +package require + +import ( + "fmt" + "io" + "testing" +) + +type requireCustomError struct{} + +func (*requireCustomError) Error() string { return "fail" } + +func TestErrorAsType(t *testing.T) { + t.Parallel() + + // success: returns the matched value, does not call FailNow + target := ErrorAsType[*requireCustomError](t, fmt.Errorf("wrap: %w", &requireCustomError{})) + if target == nil { + t.Error("expected non-nil target on success") + } + + // failure: calls FailNow + mockT := new(MockT) + ErrorAsType[*requireCustomError](mockT, io.EOF) + if !mockT.Failed { + t.Error("expected FailNow to be called") + } + + // failure on nil: calls FailNow + mockT = new(MockT) + ErrorAsType[*requireCustomError](mockT, nil) + if !mockT.Failed { + t.Error("expected FailNow to be called on nil error") + } +} + +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + // success: does not call FailNow + NotErrorAsType[*requireCustomError](t, io.EOF) + NotErrorAsType[*requireCustomError](t, nil) + + // failure: calls FailNow + mockT := new(MockT) + NotErrorAsType[*requireCustomError](mockT, fmt.Errorf("wrap: %w", &requireCustomError{})) + if !mockT.Failed { + t.Error("expected FailNow to be called") + } +} From 6cc3437e122528498d8ae832070b3868beb96431 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Thu, 12 Mar 2026 17:56:04 -0700 Subject: [PATCH 2/3] skip codegen for generic functions --- _codegen/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/_codegen/main.go b/_codegen/main.go index 0d777cab4..658934a9f 100644 --- a/_codegen/main.go +++ b/_codegen/main.go @@ -163,6 +163,12 @@ func analyzeCode(scope *types.Scope, docs *doc.Package) (imports.Importer, []tes continue } + // Skip generic functions (type parameters present) — they cannot be + // reproduced by codegen and are maintained by hand. + if sig.TypeParams() != nil && sig.TypeParams().Len() > 0 { + continue + } + funcs = append(funcs, testFunc{*outputPkg, fdocs, fn}) importer.AddImportsFrom(sig.Params()) } From d9d5e4f63283cf9b2ca601d26c028584613bbd9c Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Thu, 12 Mar 2026 17:56:16 -0700 Subject: [PATCH 3/3] expand CI to Go 1.26 --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6b40f0c98..de74286c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,6 +34,8 @@ jobs: - "1.22" - "1.23" - "1.24" + - "1.25" + - "1.26" steps: - uses: actions/checkout@v5 - name: Setup Go