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 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_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.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/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.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}}) } 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") + } +}