From caf7f90e880900e96d8ab59fc35a98c89a008cfc Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Wed, 19 Aug 2026 21:00:22 -0700 Subject: [PATCH 1/3] _codegen: support generic assertions Go 1.27 makes it possible to expose generic assertions through the same function and method surfaces as existing assertions. Teach the generator to preserve that API shape so generic additions remain generated and maintainable rather than becoming handwritten exceptions. Inspecting and rendering generic signatures relies on go/types APIs added in Go 1.18. The codegen module therefore declares Go 1.18 as its actual minimum; this does not change the Go 1.17 minimum of the main Testify module. --- _codegen/go.mod | 2 +- _codegen/internal/imports/imports.go | 48 +++++++- _codegen/main.go | 175 ++++++++++++++++++++++++--- assert/assertion_format.go.tmpl | 4 +- assert/assertion_forward.go.tmpl | 4 +- require/require.go.tmpl | 8 +- require/require_forward.go.tmpl | 4 +- 7 files changed, 220 insertions(+), 25 deletions(-) diff --git a/_codegen/go.mod b/_codegen/go.mod index c295a36ba..398184bf3 100644 --- a/_codegen/go.mod +++ b/_codegen/go.mod @@ -1,3 +1,3 @@ module github.com/stretchr/testify/_codegen -go 1.11 +go 1.18 diff --git a/_codegen/internal/imports/imports.go b/_codegen/internal/imports/imports.go index a0c7e2b6a..276d9e850 100644 --- a/_codegen/internal/imports/imports.go +++ b/_codegen/internal/imports/imports.go @@ -32,6 +32,7 @@ import ( ) type Importer interface { + AddImport(path, name string) AddImportsFrom(t types.Type) Imports() map[string]string } @@ -40,17 +41,41 @@ type Importer interface { type imports struct { currentpkg string imp map[string]string + seen map[types.Type]bool +} + +// AddImport adds an import directly. +func (imp *imports) AddImport(path, name string) { + if name == imp.currentpkg { + return + } + imp.imp[cleanImportPath(path)] = name } // AddImportsFrom adds imports used in the passed type func (imp *imports) AddImportsFrom(t types.Type) { + if imp.seen[t] { + return + } + imp.seen[t] = true + switch el := t.(type) { case *types.Basic: case *types.Slice: imp.AddImportsFrom(el.Elem()) + case *types.Array: + imp.AddImportsFrom(el.Elem()) case *types.Pointer: imp.AddImportsFrom(el.Elem()) + case *types.Map: + imp.AddImportsFrom(el.Key()) + imp.AddImportsFrom(el.Elem()) + case *types.Chan: + imp.AddImportsFrom(el.Elem()) case *types.Named: + for i := 0; i < el.TypeArgs().Len(); i++ { + imp.AddImportsFrom(el.TypeArgs().At(i)) + } pkg := el.Obj().Pkg() if pkg == nil { return @@ -58,11 +83,31 @@ func (imp *imports) AddImportsFrom(t types.Type) { if pkg.Name() == imp.currentpkg { return } - imp.imp[cleanImportPath(pkg.Path())] = pkg.Name() + imp.AddImport(pkg.Path(), pkg.Name()) case *types.Tuple: for i := 0; i < el.Len(); i++ { imp.AddImportsFrom(el.At(i).Type()) } + case *types.Signature: + imp.AddImportsFrom(el.Params()) + imp.AddImportsFrom(el.Results()) + case *types.Interface: + for i := 0; i < el.NumEmbeddeds(); i++ { + imp.AddImportsFrom(el.EmbeddedType(i)) + } + for i := 0; i < el.NumExplicitMethods(); i++ { + imp.AddImportsFrom(el.ExplicitMethod(i).Type()) + } + case *types.Struct: + for i := 0; i < el.NumFields(); i++ { + imp.AddImportsFrom(el.Field(i).Type()) + } + case *types.TypeParam: + imp.AddImportsFrom(el.Constraint()) + case *types.Union: + for i := 0; i < el.Len(); i++ { + imp.AddImportsFrom(el.Term(i).Type()) + } default: } } @@ -104,5 +149,6 @@ func New(currentpkg string) Importer { return &imports{ currentpkg: currentpkg, imp: make(map[string]string), + seen: make(map[types.Type]bool), } } diff --git a/_codegen/main.go b/_codegen/main.go index f2653e8c3..48776d553 100644 --- a/_codegen/main.go +++ b/_codegen/main.go @@ -9,6 +9,7 @@ import ( "fmt" "go/ast" "go/build" + "go/build/constraint" "go/doc" "go/format" "go/importer" @@ -32,10 +33,16 @@ var ( outputPkg = flag.String("output-package", "", "package for the resulting code") tmplFile = flag.String("template", "", "What file to load the function template from") out = flag.String("out", "", "What file to write the source code to") + goVersion string ) func main() { flag.Parse() + var err error + goVersion, err = inferGoVersion() + if err != nil { + log.Fatal(err) + } scope, docs, err := parsePackageSource(*pkg) if err != nil { @@ -59,14 +66,19 @@ func generateCode(importer imports.Importer, funcs []testFunc) error { if err != nil { return err } + if strings.Contains(funcTemplate, "assert.") { + importer.AddImport(*pkg, "assert") + } // Generate header if err := tmplHead.Execute(buff, struct { - Name string - Imports map[string]string + Name string + Imports map[string]string + GoVersion string }{ *outputPkg, importer.Imports(), + goVersion, }); err != nil { return err } @@ -94,6 +106,38 @@ func generateCode(importer imports.Importer, funcs []testFunc) error { return err } +func inferGoVersion() (string, error) { + filename := os.Getenv("GOFILE") + if filename == "" { + return "", nil + } + + source, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("read GOFILE %q: %w", filename, err) + } + for _, line := range strings.Split(string(source), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "package ") { + break + } + if !strings.HasPrefix(line, "//go:build ") { + continue + } + + expr, err := constraint.Parse(line) + if err != nil { + return "", fmt.Errorf("parse build constraint in %q: %w", filename, err) + } + tag, ok := expr.(*constraint.TagExpr) + if !ok || !strings.HasPrefix(tag.Tag, "go1.") { + return "", nil + } + return strings.TrimPrefix(tag.Tag, "go"), nil + } + return "", nil +} + func parseTemplates() (*template.Template, *template.Template, error) { tmplHead, err := template.New("header").Parse(headerTemplate) if err != nil { @@ -162,9 +206,22 @@ func analyzeCode(scope *types.Scope, docs *doc.Package) (imports.Importer, []tes if strings.HasSuffix(fdocs.Name, "f") && !*includeF { continue } + if (sig.TypeParams().Len() > 0) != (goVersion != "") { + continue + } + results := sig.Results() + if results.Len() == 0 || !types.Identical(results.At(results.Len()-1).Type(), types.Typ[types.Bool]) { + return nil, nil, fmt.Errorf("assertion function %s must return bool as its final result", fdocs.Name) + } funcs = append(funcs, testFunc{*outputPkg, fdocs, fn}) - importer.AddImportsFrom(sig.Params()) + for i := 1; i < sig.Params().Len(); i++ { + importer.AddImportsFrom(sig.Params().At(i).Type()) + } + importer.AddImportsFrom(sig.Results()) + for i := 0; i < sig.TypeParams().Len(); i++ { + importer.AddImportsFrom(sig.TypeParams().At(i).Constraint()) + } } return importer, funcs, nil } @@ -218,6 +275,10 @@ type testFunc struct { TypeInfo *types.Func } +func (f *testFunc) signature() *types.Signature { + return f.TypeInfo.Type().(*types.Signature) +} + func (f *testFunc) Qualifier(p *types.Package) string { if p == nil || p.Name() == f.CurrentPkg { return "" @@ -226,7 +287,7 @@ func (f *testFunc) Qualifier(p *types.Package) string { } func (f *testFunc) Params() string { - sig := f.TypeInfo.Type().(*types.Signature) + sig := f.signature() params := sig.Params() var p strings.Builder comma := "" @@ -251,6 +312,88 @@ func (f *testFunc) Params() string { return p.String() } +func (f *testFunc) TypeParams() string { + typeParams := f.signature().TypeParams() + if typeParams.Len() == 0 { + return "" + } + + var p strings.Builder + p.WriteByte('[') + for i := 0; i < typeParams.Len(); i++ { + if i > 0 { + p.WriteString(", ") + } + typeParam := typeParams.At(i) + p.WriteString(typeParam.Obj().Name()) + p.WriteByte(' ') + p.WriteString(types.TypeString(typeParam.Constraint(), f.Qualifier)) + } + p.WriteByte(']') + return p.String() +} + +func (f *testFunc) TypeArgs() string { + typeParams := f.signature().TypeParams() + if typeParams.Len() == 0 { + return "" + } + + var p strings.Builder + p.WriteByte('[') + for i := 0; i < typeParams.Len(); i++ { + if i > 0 { + p.WriteString(", ") + } + p.WriteString(typeParams.At(i).Obj().Name()) + } + p.WriteByte(']') + return p.String() +} + +func (f *testFunc) Results() string { + return f.formatResults(f.signature().Results()) +} + +func (f *testFunc) RequireResults() string { + results := f.signature().Results() + return f.formatResults(resultsWithoutSuccess(results)) +} + +func (f *testFunc) HasRequireResults() bool { + return f.signature().Results().Len() > 1 +} + +func (f *testFunc) RequireResultNames() string { + var names strings.Builder + for i := 0; i < f.signature().Results().Len()-1; i++ { + if i > 0 { + names.WriteString(", ") + } + fmt.Fprintf(&names, "result%d", i) + } + return names.String() +} + +func (f *testFunc) formatResults(results *types.Tuple) string { + switch results.Len() { + case 0: + return "" + case 1: + return " " + types.TypeString(results.At(0).Type(), f.Qualifier) + default: + return " " + types.TypeString(results, f.Qualifier) + } +} + +func resultsWithoutSuccess(results *types.Tuple) *types.Tuple { + vars := make([]*types.Var, results.Len()-1) + for i := range vars { + vars[i] = results.At(i) + } + return types.NewTuple(vars...) +} + func (f *testFunc) ForwardedParams() string { sig := f.TypeInfo.Type().(*types.Signature) params := sig.Params() @@ -296,14 +439,14 @@ func (f *testFunc) CommentFormat() string { // Change here if the original comment changed. comment = strings.Replace(comment, `, "external state has not changed to 'true'; still false"`, "", 1) - exp := regexp.MustCompile(replace + `\((([^()]*|\([^()]*\))*)\)`) - return exp.ReplaceAllString(comment, replace+`($1, "error message %s", "formatted")`) + exp := regexp.MustCompile(replace + `(\[[^\n]*\])?\((([^()]*|\([^()]*\))*)\)`) + return exp.ReplaceAllString(comment, replace+`$1($2, "error message %s", "formatted")`) } func (f *testFunc) CommentWithoutT(receiver string) string { - search := fmt.Sprintf("assert.%s(t, ", f.DocInfo.Name) - replace := fmt.Sprintf("%s.%s(", receiver, f.DocInfo.Name) - return strings.Replace(f.Comment(), search, replace, -1) + search := regexp.MustCompile(fmt.Sprintf(`assert\.%s(\[[^\n]*\])?\(t, `, regexp.QuoteMeta(f.DocInfo.Name))) + replace := fmt.Sprintf("%s.%s$1(", receiver, f.DocInfo.Name) + return search.ReplaceAllString(f.Comment(), replace) } func requireComment(comment string) string { @@ -345,23 +488,27 @@ func (f *testFunc) CommentRequire() string { } func (f *testFunc) CommentRequireWithoutT(receiver string) string { - assertCallRe := regexp.MustCompile(`assert\.(\w+)\(t, `) - comment := assertCallRe.ReplaceAllString(f.DocInfo.Doc, receiver+".$1(") + assertCallRe := regexp.MustCompile(`assert\.(\w+)(\[[^\n]*\])?\(t, `) + comment := assertCallRe.ReplaceAllString(f.DocInfo.Doc, receiver+".$1$2(") return requireComment(comment) } // Standard header https://go.dev/s/generatedcode. -var headerTemplate = `// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. +var headerTemplate = `{{if .GoVersion}}//go:build go{{.GoVersion}} + +{{end}}// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. package {{.Name}} +{{if .Imports}} import ( {{range $path, $name := .Imports}} {{$name}} "{{$path}}"{{end}} ) +{{end}} ` var funcTemplate = `{{.Comment}} -func (fwd *AssertionsForwarder) {{.DocInfo.Name}}({{.Params}}) bool { - return assert.{{.DocInfo.Name}}({{.ForwardedParams}}) +func (fwd *AssertionsForwarder) {{.DocInfo.Name}}{{.TypeParams}}({{.Params}}){{.Results}} { + return assert.{{.DocInfo.Name}}{{.TypeArgs}}({{.ForwardedParams}}) }` diff --git a/assert/assertion_format.go.tmpl b/assert/assertion_format.go.tmpl index d2bb0b817..20e5fe101 100644 --- a/assert/assertion_format.go.tmpl +++ b/assert/assertion_format.go.tmpl @@ -1,5 +1,5 @@ {{.CommentFormat}} -func {{.DocInfo.Name}}f(t TestingT, {{.ParamsFormat}}) bool { +func {{.DocInfo.Name}}f{{.TypeParams}}(t TestingT, {{.ParamsFormat}}){{.Results}} { if h, ok := t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(t, {{.ForwardedParamsFormat}}) + return {{.DocInfo.Name}}{{.TypeArgs}}(t, {{.ForwardedParamsFormat}}) } diff --git a/assert/assertion_forward.go.tmpl b/assert/assertion_forward.go.tmpl index 188bb9e17..639c1ca41 100644 --- a/assert/assertion_forward.go.tmpl +++ b/assert/assertion_forward.go.tmpl @@ -1,5 +1,5 @@ {{.CommentWithoutT "a"}} -func (a *Assertions) {{.DocInfo.Name}}({{.Params}}) bool { +func (a *Assertions) {{.DocInfo.Name}}{{.TypeParams}}({{.Params}}){{.Results}} { if h, ok := a.t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(a.t, {{.ForwardedParams}}) + return {{.DocInfo.Name}}{{.TypeArgs}}(a.t, {{.ForwardedParams}}) } diff --git a/require/require.go.tmpl b/require/require.go.tmpl index 6a975501e..4657ded10 100644 --- a/require/require.go.tmpl +++ b/require/require.go.tmpl @@ -1,6 +1,8 @@ {{.CommentRequire}} -func {{.DocInfo.Name}}(t TestingT, {{.Params}}) { +func {{.DocInfo.Name}}{{.TypeParams}}(t TestingT, {{.Params}}){{.RequireResults}} { if h, ok := t.(tHelper); ok { h.Helper() } - if assert.{{.DocInfo.Name}}(t, {{.ForwardedParams}}) { return } - t.FailNow() + {{if .HasRequireResults}}{{.RequireResultNames}}, success := assert.{{.DocInfo.Name}}{{.TypeArgs}}(t, {{.ForwardedParams}}) + if !success { t.FailNow() } + return {{.RequireResultNames}}{{else}}if assert.{{.DocInfo.Name}}{{.TypeArgs}}(t, {{.ForwardedParams}}) { return } + t.FailNow(){{end}} } diff --git a/require/require_forward.go.tmpl b/require/require_forward.go.tmpl index b3b751de4..7814f07fb 100644 --- a/require/require_forward.go.tmpl +++ b/require/require_forward.go.tmpl @@ -1,5 +1,5 @@ {{.CommentRequireWithoutT "a"}} -func (a *Assertions) {{.DocInfo.Name}}({{.Params}}) { +func (a *Assertions) {{.DocInfo.Name}}{{.TypeParams}}({{.Params}}){{.RequireResults}} { if h, ok := a.t.(tHelper); ok { h.Helper() } - {{.DocInfo.Name}}(a.t, {{.ForwardedParams}}) + {{if .HasRequireResults}}return {{end}}{{.DocInfo.Name}}{{.TypeArgs}}(a.t, {{.ForwardedParams}}) } From 8260cf242d6b3e869166ecd38e1b323fdf954e58 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Wed, 19 Aug 2026 21:00:22 -0700 Subject: [PATCH 2/3] assert: add ErrorAsType assertions Provide a typed alternative to ErrorAs that returns the matched error without requiring callers to declare a target variable, while retaining Testify's useful failure diagnostics. Go 1.27 generic methods allow the API to remain consistent across package functions and Assertions objects. --- assert/assertion_format_go1.27.go | 25 +++++ assert/assertion_forward_go1.27.go | 45 +++++++++ assert/assertions_go1.27.go | 60 ++++++++++++ assert/assertions_go1.27_test.go | 137 ++++++++++++++++++++++++++++ require/generate_go1.27.go | 6 ++ require/require_forward_go1.27.go | 45 +++++++++ require/require_go1.27.go | 63 +++++++++++++ require/requirements_go1.27_test.go | 79 ++++++++++++++++ 8 files changed, 460 insertions(+) create mode 100644 assert/assertion_format_go1.27.go create mode 100644 assert/assertion_forward_go1.27.go create mode 100644 assert/assertions_go1.27.go create mode 100644 assert/assertions_go1.27_test.go create mode 100644 require/generate_go1.27.go create mode 100644 require/require_forward_go1.27.go create mode 100644 require/require_go1.27.go create mode 100644 require/requirements_go1.27_test.go diff --git a/assert/assertion_format_go1.27.go b/assert/assertion_format_go1.27.go new file mode 100644 index 000000000..aa76ff5ed --- /dev/null +++ b/assert/assertion_format_go1.27.go @@ -0,0 +1,25 @@ +//go:build go1.27 + +// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. + +package assert + +// 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. +// ErrorAsTypef avoids the need for a pre-declared target variable. +// +// assert.ErrorAsTypef[*json.SyntaxError](t, err, "error message %s", "formatted") +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([]interface{}{msg}, args...)...) +} + +// NotErrorAsTypef asserts that no error in err's tree matches type E. +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([]interface{}{msg}, args...)...) +} diff --git a/assert/assertion_forward_go1.27.go b/assert/assertion_forward_go1.27.go new file mode 100644 index 000000000..47899fe42 --- /dev/null +++ b/assert/assertion_forward_go1.27.go @@ -0,0 +1,45 @@ +//go:build go1.27 + +// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. + +package 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. +// ErrorAsType avoids the need for a pre-declared target variable. +// +// a.ErrorAsType[*json.SyntaxError](err) +func (a *Assertions) ErrorAsType[E error](err error, msgAndArgs ...any) (E, bool) { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return ErrorAsType[E](a.t, err, msgAndArgs...) +} + +// 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. +// ErrorAsTypef avoids the need for a pre-declared target variable. +// +// a.ErrorAsTypef[*json.SyntaxError](err, "error message %s", "formatted") +func (a *Assertions) ErrorAsTypef[E error](err error, msg string, args ...any) (E, bool) { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return ErrorAsTypef[E](a.t, err, msg, args...) +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +func (a *Assertions) NotErrorAsType[E error](err error, msgAndArgs ...any) bool { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return NotErrorAsType[E](a.t, err, msgAndArgs...) +} + +// NotErrorAsTypef asserts that no error in err's tree matches type E. +func (a *Assertions) NotErrorAsTypef[E error](err error, msg string, args ...any) bool { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return NotErrorAsTypef[E](a.t, err, msg, args...) +} diff --git a/assert/assertions_go1.27.go b/assert/assertions_go1.27.go new file mode 100644 index 000000000..f803ae5bf --- /dev/null +++ b/assert/assertions_go1.27.go @@ -0,0 +1,60 @@ +//go:build go1.27 + +package assert + +import ( + "errors" + "fmt" + "reflect" +) + +//go:generate sh -c "cd ../_codegen && go build && cd - && ../_codegen/_codegen -output-package=assert -template=assertion_format.go.tmpl -out=assertion_format_go1.27.go" +//go:generate sh -c "cd ../_codegen && go build && cd - && ../_codegen/_codegen -output-package=assert -template=assertion_forward.go.tmpl -out=assertion_forward_go1.27.go -include-format-funcs" + +// 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. +// ErrorAsType 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 +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +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...) +} diff --git a/assert/assertions_go1.27_test.go b/assert/assertions_go1.27_test.go new file mode 100644 index 000000000..b74caa606 --- /dev/null +++ b/assert/assertions_go1.27_test.go @@ -0,0 +1,137 @@ +//go:build go1.27 + +package assert + +import ( + "errors" + "fmt" + "io" + "strings" + "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) + result := NotErrorAsType[*customError](mockT, tt.err) + mockT.checkResultAndErrMsg(t, tt.result, result, tt.resultErrMsg) + }) + } +} + +func TestErrorAsTypeGeneratedAPIs(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", &customError{}) + assertions := New(t) + + if target, ok := ErrorAsTypef[*customError](t, err, "message"); !ok || target == nil { + t.Error("ErrorAsTypef did not return the matched error") + } + if target, ok := assertions.ErrorAsType[*customError](err); !ok || target == nil { + t.Error("Assertions.ErrorAsType did not return the matched error") + } + if target, ok := assertions.ErrorAsTypef[*customError](err, "message"); !ok || target == nil { + t.Error("Assertions.ErrorAsTypef did not return the matched error") + } + if !NotErrorAsTypef[*customError](t, io.EOF, "message") { + t.Error("NotErrorAsTypef unexpectedly failed") + } + if !assertions.NotErrorAsType[*customError](io.EOF) { + t.Error("Assertions.NotErrorAsType unexpectedly failed") + } + if !assertions.NotErrorAsTypef[*customError](io.EOF, "message") { + t.Error("Assertions.NotErrorAsTypef unexpectedly failed") + } + + mockT := new(captureTestingT) + if _, ok := New(mockT).ErrorAsTypef[*customError](io.EOF, "generated message"); ok { + t.Error("Assertions.ErrorAsTypef unexpectedly succeeded") + } + if !strings.Contains(mockT.msg, "generated message") { + t.Errorf("Assertions.ErrorAsTypef did not forward its message: %q", mockT.msg) + } +} diff --git a/require/generate_go1.27.go b/require/generate_go1.27.go new file mode 100644 index 000000000..f1a5d2610 --- /dev/null +++ b/require/generate_go1.27.go @@ -0,0 +1,6 @@ +//go:build go1.27 + +package require + +//go:generate sh -c "cd ../_codegen && go build && cd - && ../_codegen/_codegen -output-package=require -template=require.go.tmpl -out=require_go1.27.go -include-format-funcs" +//go:generate sh -c "cd ../_codegen && go build && cd - && ../_codegen/_codegen -output-package=require -template=require_forward.go.tmpl -out=require_forward_go1.27.go -include-format-funcs" diff --git a/require/require_forward_go1.27.go b/require/require_forward_go1.27.go new file mode 100644 index 000000000..1c77776da --- /dev/null +++ b/require/require_forward_go1.27.go @@ -0,0 +1,45 @@ +//go:build go1.27 + +// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. + +package require + +// 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. +// ErrorAsType avoids the need for a pre-declared target variable. +// +// a.ErrorAsType[*json.SyntaxError](err) +func (a *Assertions) ErrorAsType[E error](err error, msgAndArgs ...any) E { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return ErrorAsType[E](a.t, err, msgAndArgs...) +} + +// 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. +// ErrorAsTypef avoids the need for a pre-declared target variable. +// +// a.ErrorAsTypef[*json.SyntaxError](err, "error message %s", "formatted") +func (a *Assertions) ErrorAsTypef[E error](err error, msg string, args ...any) E { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + return ErrorAsTypef[E](a.t, err, msg, args...) +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +func (a *Assertions) NotErrorAsType[E error](err error, msgAndArgs ...any) { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + NotErrorAsType[E](a.t, err, msgAndArgs...) +} + +// NotErrorAsTypef asserts that no error in err's tree matches type E. +func (a *Assertions) NotErrorAsTypef[E error](err error, msg string, args ...any) { + if h, ok := a.t.(tHelper); ok { + h.Helper() + } + NotErrorAsTypef[E](a.t, err, msg, args...) +} diff --git a/require/require_go1.27.go b/require/require_go1.27.go new file mode 100644 index 000000000..25d74eee8 --- /dev/null +++ b/require/require_go1.27.go @@ -0,0 +1,63 @@ +//go:build go1.27 + +// Code generated with github.com/stretchr/testify/_codegen; DO NOT EDIT. + +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. +// ErrorAsType avoids the need for a pre-declared target variable. +// +// require.ErrorAsType[*json.SyntaxError](t, err) +func ErrorAsType[E error](t TestingT, err error, msgAndArgs ...any) E { + if h, ok := t.(tHelper); ok { + h.Helper() + } + result0, success := assert.ErrorAsType[E](t, err, msgAndArgs...) + if !success { + t.FailNow() + } + return result0 +} + +// 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. +// ErrorAsTypef avoids the need for a pre-declared target variable. +// +// require.ErrorAsTypef[*json.SyntaxError](t, err, "error message %s", "formatted") +func ErrorAsTypef[E error](t TestingT, err error, msg string, args ...any) E { + if h, ok := t.(tHelper); ok { + h.Helper() + } + result0, success := assert.ErrorAsTypef[E](t, err, msg, args...) + if !success { + t.FailNow() + } + return result0 +} + +// NotErrorAsType asserts that no error in err's tree matches type E. +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. +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/requirements_go1.27_test.go b/require/requirements_go1.27_test.go new file mode 100644 index 000000000..fa2e95ba3 --- /dev/null +++ b/require/requirements_go1.27_test.go @@ -0,0 +1,79 @@ +//go:build go1.27 + +package require + +import ( + "fmt" + "io" + "testing" +) + +type requireCustomError struct{} + +func (*requireCustomError) Error() string { return "fail" } + +func TestErrorAsType(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", &requireCustomError{}) + if target := ErrorAsType[*requireCustomError](t, err); target == nil { + t.Error("ErrorAsType did not return the matched error") + } + + mockT := new(MockT) + ErrorAsType[*requireCustomError](mockT, io.EOF) + if !mockT.Failed { + t.Error("expected FailNow to be called") + } + + mockT = new(MockT) + ErrorAsType[*requireCustomError](mockT, nil) + if !mockT.Failed { + t.Error("expected FailNow to be called for a nil error") + } +} + +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + NotErrorAsType[*requireCustomError](t, io.EOF) + NotErrorAsType[*requireCustomError](t, nil) + + mockT := new(MockT) + NotErrorAsType[*requireCustomError](mockT, fmt.Errorf("wrap: %w", &requireCustomError{})) + if !mockT.Failed { + t.Error("expected FailNow to be called") + } +} + +func TestErrorAsTypeGeneratedAPIs(t *testing.T) { + t.Parallel() + + err := fmt.Errorf("wrap: %w", &requireCustomError{}) + requirements := New(t) + + if target := ErrorAsTypef[*requireCustomError](t, err, "message"); target == nil { + t.Error("ErrorAsTypef did not return the matched error") + } + if target := requirements.ErrorAsType[*requireCustomError](err); target == nil { + t.Error("Assertions.ErrorAsType did not return the matched error") + } + if target := requirements.ErrorAsTypef[*requireCustomError](err, "message"); target == nil { + t.Error("Assertions.ErrorAsTypef did not return the matched error") + } + NotErrorAsTypef[*requireCustomError](t, io.EOF, "message") + requirements.NotErrorAsType[*requireCustomError](io.EOF) + requirements.NotErrorAsTypef[*requireCustomError](io.EOF, "message") + + mockT := new(MockT) + New(mockT).ErrorAsTypef[*requireCustomError](io.EOF, "message") + if !mockT.Failed { + t.Error("Assertions.ErrorAsTypef did not call FailNow") + } + + mockT = new(MockT) + New(mockT).NotErrorAsTypef[*requireCustomError](err, "message") + if !mockT.Failed { + t.Error("Assertions.NotErrorAsTypef did not call FailNow") + } +} From 08520430c17436499a5138279dcdf71273f9029e Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Wed, 19 Aug 2026 20:55:16 -0700 Subject: [PATCH 3/3] CI: keep version coverage and checks reliable As stable and oldstable advance to Go 1.27 and Go 1.26, retain explicit Go 1.25 coverage so every supported release remains tested. Run language-level formatting validation with the stable toolchain because Go 1.26 cannot parse generic methods, even in files excluded by build constraints. Several CI helpers could previously report success when their underlying commands, generation checks, or action-pin validation failed. Ensure those failures are propagated and newly generated files are detected so a green build means the checks actually completed successfully. --- .ci.ghactions.sh | 28 ++++++++++++++++++++-------- .ci.gofmt.sh | 6 ++++-- .ci.gogenerate.sh | 9 +++++++-- .github/workflows/main.yml | 2 ++ 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.ci.ghactions.sh b/.ci.ghactions.sh index 5ef43ab1e..27f694a74 100755 --- a/.ci.ghactions.sh +++ b/.ci.ghactions.sh @@ -28,28 +28,40 @@ set -euo pipefail -declare -A seen +seen=("") status=0 for w in .github/workflows/*.yml do - sed -n -e '/uses: / s!^ *-\{0,1\} uses: \([^@]*\)@\([0-9a-f][0-9a-f]*\) *# *\(v.*\)$!\1 \2 \3!p' "$w" | while read -r action hash tag + actions="$(sed -n -e '/uses: / s!^ *-\{0,1\} uses: \([^@]*\)@\([0-9a-f][0-9a-f]*\) *# *\(v.*\)$!\1 \2 \3!p' "$w")" + while read -r action hash tag do - if (( ${seen["$action-$hash-$tag"]:-0} )); then - printf "\e[1;32m%s: %s@%s == %s\e[m\n" "$w" "$action" "$tag" "$hash" + if [[ -z "$action" ]]; then + continue + fi + key="$action-$hash-$tag" + duplicate=0 + for seen_key in "${seen[@]}" + do + if [[ "$seen_key" == "$key" ]]; then + duplicate=1 + break + fi + done + if (( duplicate )); then continue fi - seen["$action-$hash-$tag"]=1 + seen+=("$key") - if eval "$( curl -s -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$action/commits/$tag" | jq -r '.sha == "'"$hash"'"' )" + if curl --fail --silent --show-error -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$action/commits/$tag" | jq -e --arg hash "$hash" '.sha == $hash' >/dev/null then printf "\e[1;32m%s: %s@%s == %s\e[m\n" "$w" "$action" "$tag" "$hash" else printf "\e[1;31m%s: %s@%s != %s\e[m\n" "$w" "$action" "$tag" "$hash" status=1 fi - done + done <<< "$actions" done exit $status diff --git a/.ci.gofmt.sh b/.ci.gofmt.sh index b73a5f7c3..6fbccbfc5 100755 --- a/.ci.gofmt.sh +++ b/.ci.gofmt.sh @@ -2,7 +2,8 @@ set -euo pipefail -if [ -n "$(gofmt -l .)" ]; then +unformatted_files="$(gofmt -l .)" +if [ -n "$unformatted_files" ]; then echo "Go code is not formatted:" gofmt -d . exit 1 @@ -11,7 +12,8 @@ fi go run ./_readme-gofmt/main.go go generate ./... -if [ -n "$(git status -s -uno)" ]; then +repository_status="$(git status --short)" +if [ -n "$repository_status" ]; then echo "Go generate output does not match commit." echo "Did you forget to run go generate ./... ?" exit 1 diff --git a/.ci.gogenerate.sh b/.ci.gogenerate.sh index 5b5642094..d26f66c7d 100755 --- a/.ci.gogenerate.sh +++ b/.ci.gogenerate.sh @@ -1,16 +1,21 @@ #!/usr/bin/env bash +set -euo pipefail + # If GOMOD is defined we are running with Go Modules enabled, either # automatically or via the GO111MODULE=on environment variable. Codegen only # works with modules, so skip generation if modules is not in use. -if [[ -z "$(go env GOMOD)" ]]; then +gomod="$(go env GOMOD)" +if [[ -z "$gomod" ]]; then echo "Skipping go generate because modules not enabled and required" exit 0 fi go generate ./... -if [ -n "$(git diff)" ]; then +repository_status="$(git status --short)" +if [ -n "$repository_status" ]; then echo "Go generate had not been run" + git status --short git diff exit 1 fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96f92a4c9..e6be3d229 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,6 +17,7 @@ jobs: go-version: ${{ matrix.go_version }} - run: ./.ci.gogenerate.sh - run: ./.ci.gofmt.sh + if: matrix.go_version == 'stable' - run: ./.ci.govet.sh - run: go test -v -race ./... @@ -33,6 +34,7 @@ jobs: - "1.22" - "1.23" - "1.24" + - "1.25" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Go